updated frontend
3
frontend/.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
@@ -1,13 +1,32 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
# charset = utf-8
|
||||
# end_of_line = lf
|
||||
# indent_size = 4
|
||||
# indent_style = space
|
||||
# insert_final_newline = true
|
||||
# max_line_length = 120
|
||||
# tab_width = 4
|
||||
# trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
[*.scss]
|
||||
# indent_size = 2
|
||||
|
||||
[{*.ats, *.ts}]
|
||||
# indent_size = 2
|
||||
# tab_width = 2
|
||||
|
||||
[{*.js, *.cjs}]
|
||||
# indent_size = 2
|
||||
# tab_width = 2
|
||||
|
||||
[{*.sht, *.html, *.shtm, *.shtml, *.htm, *.ng}]
|
||||
# indent_size = 2
|
||||
# tab_width = 2
|
||||
|
||||
[{.analysis_options, *.yml, *.yaml}]
|
||||
# indent_size = 2
|
||||
|
||||
[{.babelrc, .prettierrc, .stylelintrc, .eslintrc, jest.config, *.json, *.jsb3, *.jsb2, *.bowerrc}]
|
||||
# indent_size = 2
|
||||
|
||||
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npm.taobao.org
|
||||
@@ -1,23 +0,0 @@
|
||||
FROM node:8.16.0-alpine AS frontend-build
|
||||
|
||||
ADD . /app
|
||||
WORKDIR /app
|
||||
|
||||
# install frontend
|
||||
RUN npm install -g yarn \
|
||||
&& yarn install --registry=https://registry.npm.taobao.org
|
||||
|
||||
RUN npm run build:prod
|
||||
|
||||
FROM alpine
|
||||
|
||||
#RUN apk update
|
||||
RUN apk add nginx
|
||||
COPY --from=frontend-build /app/dist /app/dist
|
||||
COPY --from=frontend-build /app/conf/crawlab.conf /etc/nginx/conf.d
|
||||
#RUN nginx -s start
|
||||
#COPY ./dist /usr/share/nginx/html
|
||||
|
||||
#EXPOSE 80
|
||||
#EXPOSE 8080
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
MIT License
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2017-present PanJiaChen
|
||||
Copyright (c) 2020, Crawlab Team
|
||||
All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
'@babel/preset-typescript'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: [
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'vue'
|
||||
],
|
||||
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
|
||||
'^.+\\.jsx?$': 'babel-jest'
|
||||
},
|
||||
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
|
||||
snapshotSerializers: [
|
||||
'jest-serializer-vue'
|
||||
],
|
||||
|
||||
testMatch: [
|
||||
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
|
||||
],
|
||||
|
||||
testURL: 'http://localhost/',
|
||||
preset: '@vue/cli-plugin-unit-jest'
|
||||
}
|
||||
194
frontend/jest.config.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
export default {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/private/var/folders/r0/jl9gx1m97tb2qpggj961z3n40000gn/T/jest_dx",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: 'v8',
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
21097
frontend/package-lock.json
generated
@@ -1,67 +1,67 @@
|
||||
{
|
||||
"name": "crawlab",
|
||||
"version": "0.4.10",
|
||||
"private": true,
|
||||
"name": "crawlab-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve --ip=0.0.0.0 --mode=development",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"serve": "vue-cli-service serve --port=8081",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint",
|
||||
"build:dev": "vue-cli-service build --mode development",
|
||||
"build:prod": "vue-cli-service build --mode production",
|
||||
"config": "vue ui",
|
||||
"serve:prod": "vue-cli-service serve --mode=production --ip=0.0.0.0"
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.13.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.13.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.13.0",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.9",
|
||||
"@tinymce/tinymce-vue": "^3.2.2",
|
||||
"ansi-to-html": "^0.6.14",
|
||||
"axios": "^0.19.2",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.32",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.0-2",
|
||||
"@popperjs/core": "^2.6.0",
|
||||
"@types/codemirror": "^0.0.103",
|
||||
"@types/echarts": "^4.9.8",
|
||||
"@types/humanize-duration": "^3.25.0",
|
||||
"@types/javascript-time-ago": "^2.0.2",
|
||||
"@types/md5": "^2.2.1",
|
||||
"@types/pinyin": "^2.8.2",
|
||||
"atom-material-icons": "^3.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"codemirror": "^5.59.1",
|
||||
"core-js": "^3.6.5",
|
||||
"cross-env": "^7.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"echarts": "^4.8.0",
|
||||
"element-ui": "^2.13.2",
|
||||
"cron-parser": "^3.5.0",
|
||||
"cronstrue": "^1.114.0",
|
||||
"dayjs": "^1.10.5",
|
||||
"echarts": "^5.1.2",
|
||||
"element-plus": "1.0.2-beta.40",
|
||||
"font-awesome": "^4.7.0",
|
||||
"github-markdown-css": "^4.0.0",
|
||||
"js-cookie": "^2.2.1",
|
||||
"humanize-duration": "^3.26.0",
|
||||
"javascript-time-ago": "^2.3.6",
|
||||
"md5": "^2.3.0",
|
||||
"node-sass": "^5.0.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"npm": "^6.14.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"path": "^0.12.7",
|
||||
"showdown": "^1.9.1",
|
||||
"vcrontab": "^0.3.5",
|
||||
"vue": "^2.6.11",
|
||||
"vue-ba": "^1.2.8",
|
||||
"vue-codemirror": "^4.0.6",
|
||||
"vue-codemirror-lite": "^1.0.4",
|
||||
"vue-github-button": "^1.2.0",
|
||||
"vue-i18n": "^8.18.1",
|
||||
"vue-router": "^3.3.2",
|
||||
"vue-tour": "^1.4.0",
|
||||
"vue-virtual-scroll-list": "^2.2.6",
|
||||
"vuex": "^3.4.0"
|
||||
"pinyin": "^2.10.2",
|
||||
"point-cluster": "^3.1.8",
|
||||
"vue": "^3.0.4",
|
||||
"vue-clipboard3": "^1.0.1",
|
||||
"vue-i18n": "^9.0.0-beta.11",
|
||||
"vue-router": "^4.0.0-0",
|
||||
"vue3-dropzone": "^0.0.7",
|
||||
"vuex": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.10.2",
|
||||
"@babel/register": "^7.10.1",
|
||||
"@vue/cli-plugin-babel": "~4.4.0",
|
||||
"@vue/cli-plugin-eslint": "^4.4.1",
|
||||
"@vue/cli-plugin-unit-jest": "~4.4.0",
|
||||
"@vue/cli-service": "^4.4.1",
|
||||
"@vue/test-utils": "^1.0.3",
|
||||
"autoprefixer": "^9.5.1",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.0.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"node-sass": "^4.14.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@types/jest": "^26.0.19",
|
||||
"@typescript-eslint/eslint-plugin": "^2.33.0",
|
||||
"@typescript-eslint/parser": "^2.33.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"@vue/eslint-config-typescript": "^5.0.2",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^7.0.0-0",
|
||||
"sass-loader": "^10.1.0",
|
||||
"scss-loader": "^0.0.1",
|
||||
"typescript": "~3.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.2 KiB |
4
frontend/public/font-awesome.min.css
vendored
@@ -1,162 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="renderer" content="webkit">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/font-awesome.min.css" type="text/css">
|
||||
|
||||
<!-- Place this tag in your head or just before your close body tag. -->
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
<style>
|
||||
#loading-placeholder {
|
||||
position: fixed;
|
||||
background: white;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#loading-placeholder .title-wrapper {
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
#loading-placeholder .title {
|
||||
font-family: "Verdana", serif;
|
||||
font-weight: 600;
|
||||
font-size: 48px;
|
||||
color: #409EFF;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
letter-spacing: -5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span {
|
||||
display: inline-block;
|
||||
animation: change-shape 1s infinite;
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(1) {
|
||||
animation-delay: calc(1s / 7 * 0 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(2) {
|
||||
animation-delay: calc(1s / 7 * 1 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(3) {
|
||||
animation-delay: calc(1s / 7 * 2 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(4) {
|
||||
animation-delay: calc(1s / 7 * 3 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(5) {
|
||||
animation-delay: calc(1s / 7 * 4 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(6) {
|
||||
animation-delay: calc(1s / 7 * 5 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .title > span:nth-child(7) {
|
||||
animation-delay: calc(1s / 7 * 6 / 2);
|
||||
}
|
||||
|
||||
#loading-placeholder .sub-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
#loading-placeholder .sub-title-wrapper .sub-title {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
font-family: "Verdana", serif;
|
||||
font-style: italic;
|
||||
color: #67C23A;
|
||||
/*color: #E6A23C;*/
|
||||
/*color: #F56C6C;*/
|
||||
}
|
||||
|
||||
#loading-placeholder .loading-text {
|
||||
text-align: center;
|
||||
font-weight: bolder;
|
||||
font-family: "Verdana", serif;
|
||||
font-style: italic;
|
||||
color: #889aa4;
|
||||
font-size: 14px;
|
||||
animation: blink-loading 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes blink-loading {
|
||||
0% {
|
||||
opacity: 100%;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes change-shape {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>Crawlab</title>
|
||||
<meta charset="utf-8">
|
||||
<meta content="IE=edge" http-equiv="X-UA-Compatible">
|
||||
<meta content="width=device-width,initial-scale=1.0" name="viewport">
|
||||
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-placeholder">
|
||||
<div style="margin-bottom: 150px">
|
||||
<div class="title-wrapper">
|
||||
<h3 class="title">
|
||||
<span>C</span>
|
||||
<span>R</span>
|
||||
<span>A</span>
|
||||
<span>W</span>
|
||||
<span>L</span>
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="sub-title-wrapper">
|
||||
<span class="sub-title"><i class="fa fa-check-square-o"></i> Easy crawling</span>
|
||||
</div>
|
||||
<div class="loading-text">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
|
||||
Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
deb http://mirrors.aliyun.com/debian/ jessie main non-free contrib
|
||||
deb http://mirrors.aliyun.com/debian/ jessie-proposed-updates main non-free contrib
|
||||
deb-src http://mirrors.aliyun.com/debian/ jessie main non-free contrib
|
||||
deb-src http://mirrors.aliyun.com/debian/ jessie-proposed-updates main non-free contrib
|
||||
@@ -1,145 +1,3 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
<router-view/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
data() {
|
||||
return {
|
||||
msgPopup: undefined
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('setting', ['setting']),
|
||||
useStats() {
|
||||
return localStorage.getItem('useStats')
|
||||
},
|
||||
uid() {
|
||||
return localStorage.getItem('uid')
|
||||
},
|
||||
sid() {
|
||||
return sessionStorage.getItem('sid')
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// set uid if first visit
|
||||
if (this.uid === undefined || this.uid === null) {
|
||||
localStorage.setItem('uid', this.$utils.encrypt.UUID())
|
||||
}
|
||||
|
||||
// set session id if starting a session
|
||||
if (this.sid === undefined || this.sid === null) {
|
||||
sessionStorage.setItem('sid', this.$utils.encrypt.UUID())
|
||||
}
|
||||
|
||||
// get latest version
|
||||
await this.$store.dispatch('version/getLatestRelease')
|
||||
if (getToken()) {
|
||||
// get user info
|
||||
await this.$store.dispatch('user/getInfo')
|
||||
// remove loading-placeholder
|
||||
const elLoading = document.querySelector('#loading-placeholder')
|
||||
elLoading.remove()
|
||||
|
||||
// send visit event
|
||||
await this.$request.put('/actions', {
|
||||
type: 'visit'
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.el-table .cell {
|
||||
line-height: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table .el-table__header th,
|
||||
.el-table .el-table__body td {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.el-table .el-table__header th .cell,
|
||||
.el-table .el-table__body td .cell {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-table .el-tag {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-weight: 900;
|
||||
/*padding: 0;*/
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-form .el-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-btn {
|
||||
margin: 0 5px;
|
||||
padding: 5px 10px;
|
||||
background: transparent;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #909399;
|
||||
}
|
||||
|
||||
.message-btn:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-btn.success {
|
||||
background: #67c23a;
|
||||
border-color: #67c23a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-btn.danger {
|
||||
background: #f56c6c;
|
||||
border-color: #f56c6c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.v-tour__target--highlighted {
|
||||
box-shadow: none !important;
|
||||
/*box-shadow: 0 0 0 4px #f56c6c !important;*/
|
||||
border: 3px solid #f56c6c !important;
|
||||
}
|
||||
|
||||
.v-step__button {
|
||||
background: #67c23a !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.v-step__button:hover {
|
||||
background: #67c23a !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
opacity: 0.9 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
const get = (path, params) => {
|
||||
return service.get(path, {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
const post = (path, data) => {
|
||||
return service.post(path, data)
|
||||
}
|
||||
|
||||
const put = (path, data) => {
|
||||
return service.put(path, data)
|
||||
}
|
||||
|
||||
const del = (path, data) => {
|
||||
return service.delete(path, {
|
||||
data
|
||||
})
|
||||
}
|
||||
const request = service.request
|
||||
|
||||
export default {
|
||||
baseUrl: service.defaults.baseURL,
|
||||
request,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
delete: del
|
||||
}
|
||||
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
262
frontend/src/assets/js/loginCanvas.js
Normal file
@@ -0,0 +1,262 @@
|
||||
function initCanvas() {
|
||||
let canvas, ctx, circ, nodes, mouse, SENSITIVITY, SIBLINGS_LIMIT, DENSITY, NODES_QTY, ANCHOR_LENGTH, MOUSE_RADIUS,
|
||||
TURBULENCE, MOUSE_MOVING_TURBULENCE, MOUSE_ANGLE_TURBULENCE, MOUSE_MOVING_RADIUS, BASE_BRIGHTNESS, RADIUS_DEGRADE,
|
||||
SAMPLE_SIZE
|
||||
|
||||
let handle
|
||||
|
||||
// how close next node must be to activate connection (in px)
|
||||
// shorter distance == better connection (line width)
|
||||
SENSITIVITY = 200
|
||||
// note that siblings limit is not 'accurate' as the node can actually have more connections than this value that's because the node accepts sibling nodes with no regard to their current connections this is acceptable because potential fix would not result in significant visual difference
|
||||
// more siblings == bigger node
|
||||
SIBLINGS_LIMIT = 10
|
||||
// default node margin
|
||||
DENSITY = 100
|
||||
// total number of nodes used (incremented after creation)
|
||||
NODES_QTY = 0
|
||||
// avoid nodes spreading
|
||||
ANCHOR_LENGTH = 100
|
||||
// highlight radius
|
||||
MOUSE_RADIUS = 200
|
||||
// turbulence of randomness
|
||||
TURBULENCE = 3
|
||||
// turbulence of mouse moving
|
||||
MOUSE_MOVING_TURBULENCE = 50
|
||||
// turbulence of mouse moving angle
|
||||
MOUSE_ANGLE_TURBULENCE = 0.002
|
||||
// moving radius of mouse
|
||||
MOUSE_MOVING_RADIUS = 600
|
||||
// base brightness
|
||||
BASE_BRIGHTNESS = 0.12
|
||||
// radius degrade
|
||||
RADIUS_DEGRADE = 0.4
|
||||
// sample size
|
||||
SAMPLE_SIZE = 0.5
|
||||
|
||||
circ = 2 * Math.PI
|
||||
nodes = []
|
||||
|
||||
canvas = document.querySelector('canvas')
|
||||
resizeWindow()
|
||||
ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
alert('Ooops! Your browser does not support canvas :\'(')
|
||||
}
|
||||
|
||||
function Mouse(x, y) {
|
||||
this.anchorX = x
|
||||
this.anchorY = y
|
||||
this.x = x
|
||||
this.y = y - MOUSE_RADIUS / 2
|
||||
this.angle = 0
|
||||
}
|
||||
|
||||
Mouse.prototype.computePosition = function () {
|
||||
// this.x = this.anchorX + MOUSE_MOVING_RADIUS / 2 * Math.sin(this.angle)
|
||||
// this.y = this.anchorY - MOUSE_MOVING_RADIUS / 2 * Math.cos(this.angle)
|
||||
}
|
||||
|
||||
Mouse.prototype.move = function () {
|
||||
let vx = Math.random() * MOUSE_MOVING_TURBULENCE
|
||||
let vy = Math.random() * MOUSE_MOVING_TURBULENCE
|
||||
if (this.x + vx + MOUSE_RADIUS / 2 > window.innerWidth || this.x + vx - MOUSE_RADIUS / 2 < 0) {
|
||||
vx = -vx
|
||||
}
|
||||
if (this.y + vy + MOUSE_RADIUS / 2 > window.innerHeight || this.y + vy - MOUSE_RADIUS / 2 < 0) {
|
||||
vy = -vy
|
||||
}
|
||||
this.x += vx
|
||||
this.y += vy
|
||||
// this.angle += Math.random() * MOUSE_ANGLE_TURBULENCE * 2 * Math.PI
|
||||
// this.angle -= Math.floor(this.angle / (2 * Math.PI)) * 2 * Math.PI
|
||||
// this.computePosition()
|
||||
}
|
||||
|
||||
function Node(x, y) {
|
||||
this.anchorX = x
|
||||
this.anchorY = y
|
||||
this.x = Math.random() * (x - (x - ANCHOR_LENGTH)) + (x - ANCHOR_LENGTH)
|
||||
this.y = Math.random() * (y - (y - ANCHOR_LENGTH)) + (y - ANCHOR_LENGTH)
|
||||
this.vx = Math.random() * TURBULENCE - 1
|
||||
this.vy = Math.random() * TURBULENCE - 1
|
||||
this.energy = Math.random() * 100
|
||||
this.radius = Math.random()
|
||||
this.siblings = []
|
||||
this.brightness = 0
|
||||
}
|
||||
|
||||
Node.prototype.drawNode = function () {
|
||||
let color = 'rgba(64, 156, 255, ' + this.brightness + ')'
|
||||
ctx.beginPath()
|
||||
ctx.arc(this.x, this.y, 2 * this.radius + 2 * this.siblings.length / SIBLINGS_LIMIT / 1.5, 0, circ)
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
Node.prototype.drawConnections = function () {
|
||||
for (let i = 0; i < this.siblings.length; i++) {
|
||||
let color = 'rgba(64, 156, 255, ' + this.brightness + ')'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(this.x, this.y)
|
||||
ctx.lineTo(this.siblings[i].x, this.siblings[i].y)
|
||||
ctx.lineWidth = 1 - calcDistance(this, this.siblings[i]) / SENSITIVITY
|
||||
ctx.strokeStyle = color
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
Node.prototype.moveNode = function () {
|
||||
this.energy -= 2
|
||||
if (this.energy < 1) {
|
||||
this.energy = Math.random() * 100
|
||||
if (this.x - this.anchorX < -ANCHOR_LENGTH) {
|
||||
this.vx = Math.random() * TURBULENCE
|
||||
} else if (this.x - this.anchorX > ANCHOR_LENGTH) {
|
||||
this.vx = Math.random() * -TURBULENCE
|
||||
} else {
|
||||
this.vx = Math.random() * 2 * TURBULENCE - TURBULENCE
|
||||
}
|
||||
if (this.y - this.anchorY < -ANCHOR_LENGTH) {
|
||||
this.vy = Math.random() * TURBULENCE
|
||||
} else if (this.y - this.anchorY > ANCHOR_LENGTH) {
|
||||
this.vy = Math.random() * -TURBULENCE
|
||||
} else {
|
||||
this.vy = Math.random() * 2 * TURBULENCE - TURBULENCE
|
||||
}
|
||||
}
|
||||
this.x += this.vx * this.energy / 100
|
||||
this.y += this.vy * this.energy / 100
|
||||
}
|
||||
|
||||
function Handle() {
|
||||
this.isStopped = false
|
||||
}
|
||||
|
||||
Handle.prototype.stop = function () {
|
||||
this.isStopped = true
|
||||
}
|
||||
|
||||
function initNodes() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
nodes = []
|
||||
for (let i = DENSITY; i < canvas.width; i += DENSITY) {
|
||||
for (let j = DENSITY; j < canvas.height; j += DENSITY) {
|
||||
nodes.push(new Node(i, j))
|
||||
NODES_QTY++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initMouse() {
|
||||
mouse = new Mouse(canvas.width / 2, canvas.height / 2)
|
||||
}
|
||||
|
||||
function initHandle() {
|
||||
handle = new Handle()
|
||||
}
|
||||
|
||||
function calcDistance(node1, node2) {
|
||||
return Math.sqrt(Math.pow(node1.x - node2.x, 2) + (Math.pow(node1.y - node2.y, 2)))
|
||||
}
|
||||
|
||||
function findSiblings() {
|
||||
let node1, node2, distance
|
||||
for (let i = 0; i < NODES_QTY; i++) {
|
||||
node1 = nodes[i]
|
||||
node1.siblings = []
|
||||
for (let j = 0; j < NODES_QTY; j++) {
|
||||
node2 = nodes[j]
|
||||
if (node1 !== node2) {
|
||||
distance = calcDistance(node1, node2)
|
||||
if (distance < SENSITIVITY) {
|
||||
if (node1.siblings.length < SIBLINGS_LIMIT) {
|
||||
node1.siblings.push(node2)
|
||||
} else {
|
||||
let node_sibling_distance = 0
|
||||
let max_distance = 0
|
||||
let s
|
||||
for (let k = 0; k < SIBLINGS_LIMIT; k++) {
|
||||
node_sibling_distance = calcDistance(node1, node1.siblings[k])
|
||||
if (node_sibling_distance > max_distance) {
|
||||
max_distance = node_sibling_distance
|
||||
s = k
|
||||
}
|
||||
}
|
||||
if (distance < max_distance) {
|
||||
node1.siblings.splice(s, 1)
|
||||
node1.siblings.push(node2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redrawScene() {
|
||||
if (handle && handle.isStopped) {
|
||||
return
|
||||
}
|
||||
resizeWindow()
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
findSiblings()
|
||||
let i, node, distance
|
||||
for (i = 0; i < NODES_QTY; i++) {
|
||||
node = nodes[i]
|
||||
distance = calcDistance({
|
||||
x: mouse.x,
|
||||
y: mouse.y
|
||||
}, node)
|
||||
node.brightness = (1 - Math.log(distance / MOUSE_RADIUS * RADIUS_DEGRADE)) * BASE_BRIGHTNESS
|
||||
}
|
||||
for (i = 0; i < NODES_QTY; i++) {
|
||||
node = nodes[i]
|
||||
if (node.brightness) {
|
||||
node.drawNode()
|
||||
node.drawConnections()
|
||||
}
|
||||
node.moveNode()
|
||||
}
|
||||
// mouse.move()
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(redrawScene)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function initHandlers() {
|
||||
document.addEventListener('resize', resizeWindow, false)
|
||||
// canvas.addEventListener('mousemove', mousemoveHandler, false)
|
||||
}
|
||||
|
||||
function resizeWindow() {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
|
||||
function mousemoveHandler(e) {
|
||||
mouse.x = e.clientX
|
||||
mouse.y = e.clientY
|
||||
}
|
||||
|
||||
function init() {
|
||||
initHandlers()
|
||||
initNodes()
|
||||
initMouse()
|
||||
initHandle()
|
||||
redrawScene()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
handle.isStopped = true
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
window.resetCanvas = reset
|
||||
}
|
||||
|
||||
(function () {
|
||||
window.initCanvas = initCanvas
|
||||
window.initCanvas()
|
||||
}())
|
||||
@@ -1,14 +1,16 @@
|
||||
<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="150" cy="150" r="150" fill="#409eff">
|
||||
</circle>
|
||||
<circle cx="150" cy="150" r="110" fill="#fff">
|
||||
</circle>
|
||||
<circle cx="150" cy="150" r="70" fill="#409eff">
|
||||
</circle>
|
||||
<path d="
|
||||
M 150,150
|
||||
L 280,225
|
||||
A 150,150 90 0 0 280,75
|
||||
" fill="#409eff">
|
||||
</path>
|
||||
<g fill="none">
|
||||
<circle cx="150" cy="150" r="130" fill="none" stroke-width="40" stroke="#409eff">
|
||||
</circle>
|
||||
<circle cx="150" cy="150" r="110" fill="white">
|
||||
</circle>
|
||||
<circle cx="150" cy="150" r="70" fill="#409eff">
|
||||
</circle>
|
||||
<path d="
|
||||
M 150,150
|
||||
L 280,225
|
||||
A 150,150 90 0 0 280,75
|
||||
" fill="#409eff">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 503 B |
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
|
||||
<span
|
||||
v-if="item.redirect==='noredirect'||index==levelList.length-1"
|
||||
class="no-redirect"
|
||||
>{{ $t(item.meta.title) }}</span>
|
||||
<a v-else @click.prevent="handleLink(item)">{{ $t(item.meta.title) }}</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pathToRegexp from 'path-to-regexp'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
levelList: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.getBreadcrumb()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getBreadcrumb()
|
||||
},
|
||||
methods: {
|
||||
getBreadcrumb() {
|
||||
let matched = this.$route.matched.filter(item => item.name)
|
||||
|
||||
const first = matched[0]
|
||||
if (first && first.name !== 'Home') {
|
||||
matched = [{ path: '/home', meta: { title: 'Home' }}].concat(matched)
|
||||
}
|
||||
|
||||
this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
|
||||
},
|
||||
pathCompile(path) {
|
||||
// To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
|
||||
const { params } = this.$route
|
||||
var toPath = pathToRegexp.compile(path)
|
||||
return toPath(params)
|
||||
},
|
||||
handleLink(item) {
|
||||
const { redirect } = item
|
||||
if (redirect) {
|
||||
this.$router.push(redirect)
|
||||
return
|
||||
}
|
||||
this.$router.push(this.getGoToPath(item))
|
||||
},
|
||||
getGoToPath(item) {
|
||||
if (item.path) {
|
||||
var path = item.path
|
||||
var startPos = path.indexOf(':')
|
||||
|
||||
if (startPos !== -1) {
|
||||
var endPos = path.indexOf('/', startPos)
|
||||
var key = path.substring(startPos + 1, endPos)
|
||||
path = path.replace(':' + key, this.$route.params[key])
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return item.redirect || item.path
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss" scoped>
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 50px;
|
||||
margin-left: 10px;
|
||||
|
||||
.no-redirect {
|
||||
color: #97a8be;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,315 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible="visible"
|
||||
width="1200px"
|
||||
:before-close="beforeClose"
|
||||
>
|
||||
<el-table
|
||||
:data="batchScheduleList"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Schedule Name')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.name" size="mini" :placeholder="$t('Schedule Name')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Cron')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.cron" size="mini" :placeholder="$t('Cron')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Spider')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.spider_id"
|
||||
size="mini"
|
||||
filterable
|
||||
:placeholder="$t('Spider')"
|
||||
@change="onSpiderChange(scope.row, $event)"
|
||||
>
|
||||
<!-- <el-option :label="$t('Same Above')" value="same-above" :placeholder="$t('Spider')"/>-->
|
||||
<el-option
|
||||
v-for="op in allSpiderList"
|
||||
:key="op._id"
|
||||
:label="`${op.display_name} (${op.name})`"
|
||||
:value="op._id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Run Type')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select v-model="scope.row.run_type" size="mini">
|
||||
<el-option value="all-nodes" :label="$t('All Nodes')" />
|
||||
<el-option value="selected-nodes" :label="$t('Selected Nodes')" />
|
||||
<el-option value="random" :label="$t('Random')" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Nodes')"
|
||||
width="250px"
|
||||
>
|
||||
<template v-if="scope.row.run_type === 'selected-nodes'" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.node_ids"
|
||||
size="mini"
|
||||
multiple
|
||||
:placeholder="$t('Nodes')"
|
||||
>
|
||||
<el-option
|
||||
v-for="n in activeNodeList"
|
||||
:key="n._id"
|
||||
:label="n.name"
|
||||
:value="n._id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Scrapy Spider')"
|
||||
width="150px"
|
||||
>
|
||||
<template v-if="getSpiderById(scope.row.spider_id).is_scrapy" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.scrapy_spider_name"
|
||||
size="mini"
|
||||
:placeholder="$t('Scrapy Spider')"
|
||||
:disabled="!scope.row.scrapy_spider_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="(n, index) in getScrapySpiderNames(scope.row.spider_id)"
|
||||
:key="index"
|
||||
:label="n"
|
||||
:value="n"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Scrapy Log Level')"
|
||||
width="120px"
|
||||
>
|
||||
<template v-if="getSpiderById(scope.row.spider_id).is_scrapy" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.scrapy_log_level"
|
||||
:placeholder="$t('Scrapy Log Level')"
|
||||
size="mini"
|
||||
>
|
||||
<el-option value="INFO" label="INFO" />
|
||||
<el-option value="DEBUG" label="DEBUG" />
|
||||
<el-option value="WARN" label="WARN" />
|
||||
<el-option value="ERROR" label="ERROR" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Parameters')"
|
||||
min-width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.param" size="mini" :placeholder="$t('Parameters')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Description')"
|
||||
width="200px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.description" size="mini" type="textarea" :placeholder="$t('Description')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
fixed="right"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button icon="el-icon-plus" size="mini" type="primary" @click="onAdd(scope.$index)" />
|
||||
<el-button icon="el-icon-delete" size="mini" type="danger" @click="onRemove(scope.$index)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="$emit('close')">{{ $t('Cancel') }}</el-button>
|
||||
<el-button type="plain" size="small" @click="reset">
|
||||
{{ $t('Reset') }}
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" :disabled="isConfirmDisabled" @click="onConfirm">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'BatchAddScheduleDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrapySpidersNamesDict: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('schedule', [
|
||||
'batchScheduleList'
|
||||
]),
|
||||
...mapState('spider', [
|
||||
'allSpiderList'
|
||||
]),
|
||||
...mapState('node', [
|
||||
'nodeList'
|
||||
]),
|
||||
activeNodeList() {
|
||||
return this.nodeList.filter(n => n.status === 'online')
|
||||
},
|
||||
validScheduleList() {
|
||||
return this.batchScheduleList.filter(d => !!d.spider_id && !!d.name && !!d.cron)
|
||||
},
|
||||
isConfirmDisabled() {
|
||||
if (this.validScheduleList.length === 0) {
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < this.validScheduleList.length; i++) {
|
||||
const row = this.validScheduleList[i]
|
||||
const spider = this.getSpiderById(row.spider_id)
|
||||
if (!spider) {
|
||||
return true
|
||||
}
|
||||
if (spider.is_scrapy && !row.scrapy_spider_name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
scrapySpidersIds() {
|
||||
return Array.from(new Set(this.validScheduleList.filter(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
return spider && spider.is_scrapy
|
||||
}).map(d => d.spider_id)))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible() {
|
||||
if (this.visible) {
|
||||
this.fetchAllScrapySpiderNames()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
beforeClose() {
|
||||
this.$emit('close')
|
||||
},
|
||||
reset() {
|
||||
this.$store.commit('task/SET_BATCH_CRAWL_LIST', [])
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.batchScheduleList.push({
|
||||
spider_id: '',
|
||||
run_type: 'random',
|
||||
param: '',
|
||||
scrapy_log_level: 'INFO'
|
||||
})
|
||||
}
|
||||
this.$st.sendEv('批量添加定时任务', '重置')
|
||||
},
|
||||
getSpiderById(id) {
|
||||
return this.allSpiderList.filter(d => d._id === id)[0] || {}
|
||||
},
|
||||
async onSpiderChange(row, id) {
|
||||
const spider = this.getSpiderById(id)
|
||||
if (!spider) return
|
||||
if (spider.is_scrapy) {
|
||||
await this.fetchScrapySpiderNames(id)
|
||||
if (this.scrapySpidersNamesDict[id] && this.scrapySpidersNamesDict[id].length > 0) {
|
||||
this.$set(row, 'scrapy_spider_name', this.scrapySpidersNamesDict[id][0])
|
||||
}
|
||||
}
|
||||
this.$st.sendEv('批量添加定时任务', '选择爬虫')
|
||||
},
|
||||
getScrapySpiderNames(id) {
|
||||
if (!this.scrapySpidersNamesDict[id]) return []
|
||||
return this.scrapySpidersNamesDict[id]
|
||||
},
|
||||
async onConfirm() {
|
||||
const res = await this.$request.put('/schedules/batch', this.validScheduleList.map(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
// Scrapy爬虫特殊处理
|
||||
if (spider.type === 'customized' && spider.is_scrapy) {
|
||||
d.param = `${this.scrapySpidersNamesDict[d.spider_id] ? this.scrapySpidersNamesDict[d.spider_id][0] : ''} --loglevel=${d.scrapy_log_level} ${d.param || ''}`
|
||||
}
|
||||
// cron特殊处理
|
||||
d.cron = '0 ' + d.cron
|
||||
return d
|
||||
}))
|
||||
if (res.status !== 200) {
|
||||
this.$message.error(res.data.error)
|
||||
return
|
||||
}
|
||||
this.reset()
|
||||
this.$emit('close')
|
||||
this.$emit('confirm')
|
||||
this.$st.sendEv('批量添加定时任务', '确认添加')
|
||||
},
|
||||
async fetchScrapySpiderNames(id) {
|
||||
if (!this.scrapySpidersNamesDict[id]) {
|
||||
const res = await this.$request.get(`/spiders/${id}/scrapy/spiders`)
|
||||
this.$set(this.scrapySpidersNamesDict, id, res.data.data)
|
||||
}
|
||||
},
|
||||
async fetchAllScrapySpiderNames() {
|
||||
await Promise.all(this.scrapySpidersIds.map(async id => {
|
||||
await this.fetchScrapySpiderNames(id)
|
||||
}))
|
||||
this.validScheduleList.filter(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
return spider && spider.is_scrapy
|
||||
}).forEach(row => {
|
||||
const id = row.spider_id
|
||||
if (this.scrapySpidersNamesDict[id] && this.scrapySpidersNamesDict[id].length > 0) {
|
||||
this.$set(row, 'scrapy_spider_name', this.scrapySpidersNamesDict[id][0])
|
||||
}
|
||||
})
|
||||
},
|
||||
onAdd(rowIndex) {
|
||||
this.batchScheduleList.splice(rowIndex + 1, 0, {
|
||||
spider_id: '',
|
||||
run_type: 'random',
|
||||
param: '',
|
||||
scrapy_log_level: 'INFO'
|
||||
})
|
||||
this.$st.sendEv('批量添加定时任务', '添加')
|
||||
},
|
||||
onRemove(rowIndex) {
|
||||
this.batchScheduleList.splice(rowIndex, 1)
|
||||
this.$st.sendEv('批量添加定时任务', '删除')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-table .el-button {
|
||||
padding: 7px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,285 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:visible="visible"
|
||||
width="1200px"
|
||||
:before-close="beforeClose"
|
||||
>
|
||||
<el-table
|
||||
:data="batchCrawlList"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Spider')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.spider_id"
|
||||
size="mini"
|
||||
filterable
|
||||
:placeholder="$t('Spider')"
|
||||
@change="onSpiderChange(scope.row, $event)"
|
||||
>
|
||||
<!-- <el-option :label="$t('Same Above')" value="same-above" :placeholder="$t('Spider')"/>-->
|
||||
<el-option
|
||||
v-for="op in allSpiderList"
|
||||
:key="op._id"
|
||||
:label="`${op.display_name} (${op.name})`"
|
||||
:value="op._id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Run Type')"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select v-model="scope.row.run_type" size="mini">
|
||||
<el-option value="all-nodes" :label="$t('All Nodes')"/>
|
||||
<el-option value="selected-nodes" :label="$t('Selected Nodes')"/>
|
||||
<el-option value="random" :label="$t('Random')"/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Nodes')"
|
||||
width="250px"
|
||||
>
|
||||
<template v-if="scope.row.run_type === 'selected-nodes'" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.node_ids"
|
||||
size="mini"
|
||||
multiple
|
||||
:placeholder="$t('Nodes')"
|
||||
>
|
||||
<el-option
|
||||
v-for="n in activeNodeList"
|
||||
:key="n._id"
|
||||
:label="n.name"
|
||||
:value="n._id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Scrapy Spider')"
|
||||
width="150px"
|
||||
>
|
||||
<template v-if="getSpiderById(scope.row.spider_id).is_scrapy" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.scrapy_spider_name"
|
||||
size="mini"
|
||||
:placeholder="$t('Scrapy Spider')"
|
||||
:disabled="!scope.row.scrapy_spider_name"
|
||||
>
|
||||
<el-option
|
||||
v-for="(n, index) in getScrapySpiderNames(scope.row.spider_id)"
|
||||
:key="index"
|
||||
:label="n"
|
||||
:value="n"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Scrapy Log Level')"
|
||||
width="120px"
|
||||
>
|
||||
<template v-if="getSpiderById(scope.row.spider_id).is_scrapy" slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.scrapy_log_level"
|
||||
:placeholder="$t('Scrapy Log Level')"
|
||||
size="mini"
|
||||
>
|
||||
<el-option value="INFO" label="INFO" />
|
||||
<el-option value="DEBUG" label="DEBUG" />
|
||||
<el-option value="WARN" label="WARN" />
|
||||
<el-option value="ERROR" label="ERROR" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Parameters')"
|
||||
min-width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.param" size="mini" :placeholder="$t('Parameters')"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
fixed="right"
|
||||
width="150px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button icon="el-icon-plus" size="mini" type="primary" @click="onAdd(scope.$index)"/>
|
||||
<el-button icon="el-icon-delete" size="mini" type="danger" @click="onRemove(scope.$index)"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="$emit('close')">{{ $t('Cancel') }}</el-button>
|
||||
<el-button type="plain" size="small" @click="reset">
|
||||
{{ $t('Reset') }}
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" :disabled="isConfirmDisabled" @click="onConfirm">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'BatchCrawlDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrapySpidersNamesDict: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('task', [
|
||||
'batchCrawlList'
|
||||
]),
|
||||
...mapState('spider', [
|
||||
'allSpiderList'
|
||||
]),
|
||||
...mapState('node', [
|
||||
'nodeList'
|
||||
]),
|
||||
activeNodeList() {
|
||||
return this.nodeList.filter(n => n.status === 'online')
|
||||
},
|
||||
validCrawlList() {
|
||||
return this.batchCrawlList.filter(d => !!d.spider_id)
|
||||
},
|
||||
isConfirmDisabled() {
|
||||
if (this.validCrawlList.length === 0) {
|
||||
return true
|
||||
}
|
||||
for (let i = 0; i < this.validCrawlList.length; i++) {
|
||||
const row = this.validCrawlList[i]
|
||||
const spider = this.getSpiderById(row.spider_id)
|
||||
if (!spider) {
|
||||
return true
|
||||
}
|
||||
if (spider.is_scrapy && !row.scrapy_spider_name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
scrapySpidersIds() {
|
||||
return Array.from(new Set(this.validCrawlList.filter(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
return spider && spider.is_scrapy
|
||||
}).map(d => d.spider_id)))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible() {
|
||||
if (this.visible) {
|
||||
this.fetchAllScrapySpiderNames()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
beforeClose() {
|
||||
this.$emit('close')
|
||||
},
|
||||
reset() {
|
||||
this.$store.commit('task/SET_BATCH_CRAWL_LIST', [])
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.batchCrawlList.push({
|
||||
spider_id: '',
|
||||
run_type: 'random',
|
||||
param: '',
|
||||
scrapy_log_level: 'INFO'
|
||||
})
|
||||
}
|
||||
this.$st.sendEv('批量运行', '重置')
|
||||
},
|
||||
getSpiderById(id) {
|
||||
return this.allSpiderList.filter(d => d._id === id)[0] || {}
|
||||
},
|
||||
async onSpiderChange(row, id) {
|
||||
const spider = this.getSpiderById(id)
|
||||
if (!spider) return
|
||||
if (spider.is_scrapy) {
|
||||
await this.fetchScrapySpiderNames(id)
|
||||
if (this.scrapySpidersNamesDict[id] && this.scrapySpidersNamesDict[id].length > 0) {
|
||||
this.$set(row, 'scrapy_spider_name', this.scrapySpidersNamesDict[id][0])
|
||||
}
|
||||
}
|
||||
this.$st.sendEv('批量运行', '选择爬虫')
|
||||
},
|
||||
getScrapySpiderNames(id) {
|
||||
if (!this.scrapySpidersNamesDict[id]) return []
|
||||
return this.scrapySpidersNamesDict[id]
|
||||
},
|
||||
async onConfirm() {
|
||||
await this.$request.put('/tasks/batch', this.validCrawlList.map(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
// Scrapy爬虫特殊处理
|
||||
if (spider.type === 'customized' && spider.is_scrapy) {
|
||||
d.param = `${this.scrapySpidersNamesDict[d.spider_id] ? this.scrapySpidersNamesDict[d.spider_id][0] : ''} --loglevel=${d.scrapy_log_level} ${d.param || ''}`
|
||||
}
|
||||
return d
|
||||
}))
|
||||
this.reset()
|
||||
this.$emit('close')
|
||||
this.$emit('confirm')
|
||||
this.$st.sendEv('批量运行', '确认批量运行')
|
||||
},
|
||||
async fetchScrapySpiderNames(id) {
|
||||
if (!this.scrapySpidersNamesDict[id]) {
|
||||
const res = await this.$request.get(`/spiders/${id}/scrapy/spiders`)
|
||||
this.$set(this.scrapySpidersNamesDict, id, res.data.data)
|
||||
}
|
||||
},
|
||||
async fetchAllScrapySpiderNames() {
|
||||
await Promise.all(this.scrapySpidersIds.map(async id => {
|
||||
await this.fetchScrapySpiderNames(id)
|
||||
}))
|
||||
this.validCrawlList.filter(d => {
|
||||
const spider = this.getSpiderById(d.spider_id)
|
||||
return spider && spider.is_scrapy
|
||||
}).forEach(row => {
|
||||
const id = row.spider_id
|
||||
if (this.scrapySpidersNamesDict[id] && this.scrapySpidersNamesDict[id].length > 0) {
|
||||
this.$set(row, 'scrapy_spider_name', this.scrapySpidersNamesDict[id][0])
|
||||
}
|
||||
})
|
||||
},
|
||||
onAdd(rowIndex) {
|
||||
this.batchCrawlList.splice(rowIndex + 1, 0, {
|
||||
spider_id: '',
|
||||
run_type: 'random',
|
||||
param: '',
|
||||
scrapy_log_level: 'INFO'
|
||||
})
|
||||
this.$st.sendEv('批量运行', '添加')
|
||||
},
|
||||
onRemove(rowIndex) {
|
||||
this.batchCrawlList.splice(rowIndex, 1)
|
||||
this.$st.sendEv('批量运行', '删除')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-table .el-button {
|
||||
padding: 7px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,362 +0,0 @@
|
||||
<template>
|
||||
<div class="crawl-confirm-dialog-wrapper">
|
||||
<parameters-dialog
|
||||
:visible="isParametersVisible"
|
||||
:param="form.param"
|
||||
@confirm="onParametersConfirm"
|
||||
@close="isParametersVisible = false"
|
||||
/>
|
||||
<el-dialog
|
||||
:title="$t('Notification')"
|
||||
:visible="visible"
|
||||
class="crawl-confirm-dialog"
|
||||
width="580px"
|
||||
:before-close="beforeClose"
|
||||
>
|
||||
<div style="margin-bottom: 20px;">{{ $t('Are you sure to run this spider?') }}</div>
|
||||
<el-form ref="form" label-width="140px" :model="form">
|
||||
<el-form-item :label="$t('Run Type')" prop="runType" required inline-message>
|
||||
<el-select v-model="form.runType" :placeholder="$t('Run Type')">
|
||||
<el-option value="all-nodes" :label="$t('All Nodes')" />
|
||||
<el-option value="selected-nodes" :label="$t('Selected Nodes')" />
|
||||
<el-option value="random" :label="$t('Random')" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.runType === 'selected-nodes'"
|
||||
prop="nodeIds"
|
||||
:label="$t('Node')"
|
||||
required
|
||||
inline-message
|
||||
>
|
||||
<el-select v-model="form.nodeIds" :placeholder="$t('Node')" multiple clearable>
|
||||
<el-option
|
||||
v-for="op in nodeList"
|
||||
:key="op._id"
|
||||
:value="op._id"
|
||||
:disabled="isNodeDisabled(op)"
|
||||
:label="op.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.is_scrapy && !multiple"
|
||||
:label="$t('Scrapy Spider')"
|
||||
prop="spider"
|
||||
required
|
||||
inline-message
|
||||
>
|
||||
<el-select v-model="form.spider" :placeholder="$t('Scrapy Spider')" :disabled="isLoading">
|
||||
<el-option
|
||||
v-for="s in spiderForm.spider_names"
|
||||
:key="s"
|
||||
:label="s"
|
||||
:value="s"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.is_scrapy || (multiple && scrapySpiders.length > 0)"
|
||||
:label="$t('Scrapy Log Level')"
|
||||
prop="scrapy_log_level"
|
||||
required
|
||||
inline-message
|
||||
>
|
||||
<el-select v-model="form.scrapy_log_level" :placeholder="$t('Scrapy Log Level')">
|
||||
<el-option value="INFO" label="INFO" />
|
||||
<el-option value="DEBUG" label="DEBUG" />
|
||||
<el-option value="WARN" label="WARN" />
|
||||
<el-option value="ERROR" label="ERROR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="spiderForm.type === 'customized'" :label="$t('Parameters')" prop="param" inline-message>
|
||||
<template v-if="spiderForm.is_scrapy && !multiple">
|
||||
<el-input v-model="form.param" :placeholder="$t('Parameters')" class="param-input" />
|
||||
<el-button type="primary" icon="el-icon-edit" class="param-btn" @click="onOpenParameters" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input v-model="form.param" :placeholder="$t('Parameters')" />
|
||||
</template>
|
||||
</el-form-item>
|
||||
<el-form-item class="checkbox-wrapper">
|
||||
<div>
|
||||
<el-checkbox v-model="isAllowDisclaimer" />
|
||||
<span v-if="lang === 'zh'" style="margin-left: 5px">
|
||||
我已阅读并同意
|
||||
<a href="javascript:" @click="onClickDisclaimer">
|
||||
《免责声明》
|
||||
</a>
|
||||
所有内容
|
||||
</span>
|
||||
<span v-else style="margin-left: 5px">
|
||||
I have read and agree all content in
|
||||
<a href="javascript:" @click="onClickDisclaimer">
|
||||
Disclaimer
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!spiderForm.is_long_task && !multiple">
|
||||
<el-checkbox v-model="isRedirect" />
|
||||
<span style="margin-left: 5px">{{ $t('Redirect to task detail') }}</span>
|
||||
</div>
|
||||
<div v-if="false">
|
||||
<el-checkbox v-model="isRetry" />
|
||||
<span style="margin-left: 5px">{{ $t('Retry (Maximum 5 Times)') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="$emit('close')">{{ $t('Cancel') }}</el-button>
|
||||
<el-button type="primary" size="small" :disabled="isConfirmDisabled" @click="onConfirm">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import ParametersDialog from './ParametersDialog'
|
||||
|
||||
export default {
|
||||
name: 'CrawlConfirmDialog',
|
||||
components: { ParametersDialog },
|
||||
props: {
|
||||
spiderId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
spiders: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
runType: 'random',
|
||||
nodeIds: undefined,
|
||||
spider: undefined,
|
||||
scrapy_log_level: 'INFO',
|
||||
param: '',
|
||||
nodeList: []
|
||||
},
|
||||
isAllowDisclaimer: true,
|
||||
isRetry: false,
|
||||
isRedirect: true,
|
||||
isLoading: false,
|
||||
isParametersVisible: false,
|
||||
scrapySpidersNamesDict: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('setting', [
|
||||
'setting'
|
||||
]),
|
||||
...mapState('lang', [
|
||||
'lang'
|
||||
]),
|
||||
isConfirmDisabled() {
|
||||
if (this.isLoading) return true
|
||||
if (!this.isAllowDisclaimer) return true
|
||||
return false
|
||||
},
|
||||
scrapySpiders() {
|
||||
return this.spiders.filter(d => d.type === 'customized' && d.is_scrapy)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(value) {
|
||||
if (value) {
|
||||
this.onOpen()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
beforeClose() {
|
||||
this.$emit('close')
|
||||
},
|
||||
beforeParameterClose() {
|
||||
this.isParametersVisible = false
|
||||
},
|
||||
async fetchScrapySpiderName(id) {
|
||||
const res = await this.$request.get(`/spiders/${id}/scrapy/spiders`)
|
||||
this.scrapySpidersNamesDict[id] = res.data.data
|
||||
},
|
||||
onConfirm() {
|
||||
this.$refs['form'].validate(async valid => {
|
||||
if (!valid) return
|
||||
|
||||
// 请求响应
|
||||
let res
|
||||
|
||||
if (!this.multiple) {
|
||||
// 运行单个爬虫
|
||||
|
||||
// 参数
|
||||
let param = this.form.param
|
||||
|
||||
// Scrapy爬虫特殊处理
|
||||
if (this.spiderForm.type === 'customized' && this.spiderForm.is_scrapy) {
|
||||
param = `${this.form.spider} --loglevel=${this.form.scrapy_log_level} ${this.form.param}`
|
||||
}
|
||||
|
||||
// 发起请求
|
||||
res = await this.$store.dispatch('spider/crawlSpider', {
|
||||
spiderId: this.spiderId,
|
||||
nodeIds: this.form.nodeIds,
|
||||
param,
|
||||
runType: this.form.runType
|
||||
})
|
||||
} else {
|
||||
// 运行多个爬虫
|
||||
|
||||
// 发起请求
|
||||
res = await this.$store.dispatch('spider/crawlSelectedSpiders', {
|
||||
nodeIds: this.form.nodeIds,
|
||||
runType: this.form.runType,
|
||||
taskParams: this.spiders.map(d => {
|
||||
// 参数
|
||||
let param = this.form.param
|
||||
|
||||
// Scrapy爬虫特殊处理
|
||||
if (d.type === 'customized' && d.is_scrapy) {
|
||||
param = `${this.scrapySpidersNamesDict[d._id] ? this.scrapySpidersNamesDict[d._id][0] : ''} --loglevel=${this.form.scrapy_log_level} ${this.form.param}`
|
||||
}
|
||||
|
||||
return {
|
||||
spider_id: d._id,
|
||||
param
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 消息提示
|
||||
this.$message.success(this.$t('A task has been scheduled successfully'))
|
||||
|
||||
this.$emit('close')
|
||||
if (this.multiple) {
|
||||
this.$st.sendEv('爬虫确认', '确认批量运行', this.form.runType)
|
||||
} else {
|
||||
this.$st.sendEv('爬虫确认', '确认运行', this.form.runType)
|
||||
}
|
||||
|
||||
// 是否重定向
|
||||
if (
|
||||
this.isRedirect &&
|
||||
!this.spiderForm.is_long_task &&
|
||||
!this.multiple
|
||||
) {
|
||||
// 返回任务id
|
||||
const id = res.data.data[0]
|
||||
this.$router.push('/tasks/' + id)
|
||||
this.$st.sendEv('爬虫确认', '跳转到任务详情')
|
||||
}
|
||||
|
||||
this.$emit('confirm')
|
||||
})
|
||||
},
|
||||
onClickDisclaimer() {
|
||||
this.$router.push('/disclaimer')
|
||||
},
|
||||
async onOpen() {
|
||||
// 节点列表
|
||||
this.$request.get('/nodes', {}).then(response => {
|
||||
this.nodeList = response.data.data.map(d => {
|
||||
d.systemInfo = {
|
||||
os: '',
|
||||
arch: '',
|
||||
num_cpu: '',
|
||||
executables: []
|
||||
}
|
||||
return d
|
||||
})
|
||||
})
|
||||
|
||||
// 爬虫列表
|
||||
if (!this.multiple) {
|
||||
// 单个爬虫
|
||||
this.isLoading = true
|
||||
try {
|
||||
await this.$store.dispatch('spider/getSpiderData', this.spiderId)
|
||||
if (this.spiderForm.is_scrapy) {
|
||||
await this.$store.dispatch('spider/getSpiderScrapySpiders', this.spiderId)
|
||||
if (this.spiderForm.spider_names && this.spiderForm.spider_names.length > 0) {
|
||||
this.$set(this.form, 'spider', this.spiderForm.spider_names[0])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
} else {
|
||||
// 多个爬虫
|
||||
this.isLoading = true
|
||||
try {
|
||||
// 遍历 Scrapy 爬虫列表
|
||||
await Promise.all(this.scrapySpiders.map(async d => {
|
||||
return this.fetchScrapySpiderName(d._id)
|
||||
}))
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenParameters() {
|
||||
this.isParametersVisible = true
|
||||
},
|
||||
onParametersConfirm(value) {
|
||||
this.form.param = value
|
||||
this.isParametersVisible = false
|
||||
},
|
||||
isNodeDisabled(node) {
|
||||
if (node.status !== 'online') return true
|
||||
if (node.is_master && this.setting.run_on_master === 'N') return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crawl-confirm-dialog >>> .el-form .el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.crawl-confirm-dialog >>> .checkbox-wrapper a {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.crawl-confirm-dialog >>> .param-input {
|
||||
width: calc(100% - 56px);
|
||||
}
|
||||
|
||||
.crawl-confirm-dialog >>> .param-input .el-input__inner {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.crawl-confirm-dialog >>> .param-btn {
|
||||
width: 56px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="$t('Parameters')"
|
||||
:visible="visible"
|
||||
:before-close="beforeClose"
|
||||
class="parameters-dialog"
|
||||
width="720px"
|
||||
>
|
||||
<div class="action-wrapper">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="onAdd"
|
||||
>
|
||||
{{ $t('Add') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="paramData"
|
||||
border
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Parameter Type')"
|
||||
width="100px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select v-model="scope.row.type" size="small">
|
||||
<el-option
|
||||
:label="$t('Spider')"
|
||||
value="spider"
|
||||
/>
|
||||
<el-option
|
||||
:label="$t('Setting')"
|
||||
value="setting"
|
||||
/>
|
||||
<el-option
|
||||
:label="$t('Other')"
|
||||
value="other"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Parameter Name')"
|
||||
width="240px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-autocomplete
|
||||
v-if="scope.row.type === 'setting'"
|
||||
v-model="scope.row.name"
|
||||
size="small"
|
||||
suffix-icon="el-icon-edit"
|
||||
:fetch-suggestions="querySearch"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="scope.row.type === 'spider'"
|
||||
v-model="scope.row.name"
|
||||
size="small"
|
||||
suffix-icon="el-icon-edit"
|
||||
/>
|
||||
<div v-else style="text-align: center">
|
||||
N/A
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Parameter Value')"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.value" size="small" suffix-icon="el-icon-edit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
width="60px"
|
||||
align="center"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="action-btn-wrapper">
|
||||
<el-button type="danger" icon="el-icon-delete" size="mini" circle @click="onRemove(scope.$index)" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="$emit('close')">{{ $t('Cancel') }}</el-button>
|
||||
<el-button type="primary" size="small" @click="onConfirm">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ParametersDialog',
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
param: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
paramData: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(value) {
|
||||
if (value) this.initParamData()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
beforeClose() {
|
||||
this.$emit('close')
|
||||
},
|
||||
initParamData() {
|
||||
const mArr = this.param.match(/((?:-[-a-zA-Z0-9] )?(?:\w+=)?\w+)/g)
|
||||
if (!mArr) {
|
||||
this.paramData = []
|
||||
this.paramData.push({ type: 'spider', name: '', value: '' })
|
||||
return
|
||||
}
|
||||
this.paramData = []
|
||||
mArr.forEach(s => {
|
||||
s = s.trim()
|
||||
const d = {}
|
||||
const arr = s.split(' ')
|
||||
if (arr.length === 1) {
|
||||
d.type = 'other'
|
||||
d.value = s
|
||||
} else {
|
||||
const arr2 = arr[1].split('=')
|
||||
d.name = arr2[0]
|
||||
d.value = arr2[1]
|
||||
if (arr[0] === '-a') {
|
||||
d.type = 'spider'
|
||||
} else if (arr[0] === '-s') {
|
||||
d.type = 'setting'
|
||||
} else {
|
||||
d.type = 'other'
|
||||
d.value = s
|
||||
}
|
||||
}
|
||||
this.paramData.push(d)
|
||||
})
|
||||
if (this.paramData.length === 0) {
|
||||
this.paramData.push({ type: 'spider', name: '', value: '' })
|
||||
}
|
||||
},
|
||||
onConfirm() {
|
||||
const param = this.paramData
|
||||
.filter(d => d.value)
|
||||
.map(d => {
|
||||
let s = ''
|
||||
if (d.type === 'setting') {
|
||||
s = `-s ${d.name}=${d.value}`
|
||||
} else if (d.type === 'spider') {
|
||||
s = `-a ${d.name}=${d.value}`
|
||||
} else if (d.type === 'other') {
|
||||
s = d.value
|
||||
}
|
||||
return s
|
||||
})
|
||||
.filter(s => !!s)
|
||||
.join(' ')
|
||||
this.$emit('confirm', param)
|
||||
},
|
||||
onRemove(index) {
|
||||
this.paramData.splice(index, 1)
|
||||
},
|
||||
onAdd() {
|
||||
this.paramData.push({ type: 'spider', name: '', value: '' })
|
||||
},
|
||||
querySearch(queryString, cb) {
|
||||
let data = this.$utils.scrapy.settingParamNames
|
||||
if (!queryString) {
|
||||
return cb(data.map(s => {
|
||||
return { value: s, label: s }
|
||||
}))
|
||||
}
|
||||
data = data
|
||||
.filter(s => s.match(new RegExp(queryString, 'i')))
|
||||
.sort((a, b) => a < b ? 1 : -1)
|
||||
cb(data.map(s => {
|
||||
return { value: s, label: s }
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parameters-dialog >>> .el-table td,
|
||||
.parameters-dialog >>> .el-table td .cell {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.parameters-dialog >>> .el-table td .cell .el-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parameters-dialog >>> .el-table td .cell .el-input__inner {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.parameters-dialog .action-wrapper {
|
||||
margin-bottom: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.parameters-dialog .action-btn-wrapper {
|
||||
}
|
||||
</style>
|
||||
@@ -1,465 +0,0 @@
|
||||
<style lang="scss" scoped>
|
||||
#change-crontab {
|
||||
.language {
|
||||
position: absolute;
|
||||
right: 25px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cron-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tabBody {
|
||||
.el-row {
|
||||
margin: 10px 0;
|
||||
|
||||
.long {
|
||||
.el-select {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input-number {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div id="change-crontab">
|
||||
<div class="cron-wrapper">
|
||||
<label>
|
||||
{{ $t('Cron Expression') }}:
|
||||
</label>
|
||||
<el-tag type="success" size="small">
|
||||
{{ cron }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-tabs type="border-card">
|
||||
<el-tab-pane>
|
||||
<span slot="label"><i class="el-icon-date" /> {{ text.Minutes.name }}</span>
|
||||
<div class="tabBody">
|
||||
<el-row>
|
||||
<el-radio v-model="minute.cronEvery" label="1">{{ text.Minutes.every }}</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="minute.cronEvery" label="2">{{ text.Minutes.interval[0] }}
|
||||
<el-input-number v-model="minute.incrementIncrement" size="small" :min="0" :max="59" />
|
||||
{{ text.Minutes.interval[1] }}
|
||||
<el-input-number v-model="minute.incrementStart" size="small" :min="0" :max="59" />
|
||||
{{ text.Minutes.interval[2]||'' }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="minute.cronEvery" class="long" label="3">{{ text.Minutes.specific }}
|
||||
<el-select v-model="minute.specificSpecific" size="small" multiple>
|
||||
<el-option v-for="val in 60" :key="val" :value="(val-1).toString()" :label="val-1" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="minute.cronEvery" label="4">{{ text.Minutes.cycle[0] }}
|
||||
<el-input-number v-model="minute.rangeStart" size="small" :min="0" :max="59" />
|
||||
{{ text.Minutes.cycle[1] }}
|
||||
<el-input-number v-model="minute.rangeEnd" size="small" :min="0" :max="59" />
|
||||
{{ text.Minutes.cycle[2] }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane>
|
||||
<span slot="label"><i class="el-icon-date" /> {{ text.Hours.name }}</span>
|
||||
<div class="tabBody">
|
||||
<el-row>
|
||||
<el-radio v-model="hour.cronEvery" label="1">{{ text.Hours.every }}</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="hour.cronEvery" label="2">{{ text.Hours.interval[0] }}
|
||||
<el-input-number v-model="hour.incrementIncrement" size="small" :min="0" :max="23" />
|
||||
{{ text.Hours.interval[1] }}
|
||||
<el-input-number v-model="hour.incrementStart" size="small" :min="0" :max="23" />
|
||||
{{ text.Hours.interval[2] }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="hour.cronEvery" class="long" label="3">{{ text.Hours.specific }}
|
||||
<el-select v-model="hour.specificSpecific" size="small" multiple>
|
||||
<el-option v-for="val in 24" :key="val" :value="(val-1).toString()" :label="val-1" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="hour.cronEvery" label="4">{{ text.Hours.cycle[0] }}
|
||||
<el-input-number v-model="hour.rangeStart" size="small" :min="0" :max="23" />
|
||||
{{ text.Hours.cycle[1] }}
|
||||
<el-input-number v-model="hour.rangeEnd" size="small" :min="0" :max="23" />
|
||||
{{ text.Hours.cycle[2] }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane>
|
||||
<span slot="label"><i class="el-icon-date" /> {{ text.Day.name }}</span>
|
||||
<div class="tabBody">
|
||||
<el-row>
|
||||
<el-radio v-model="day.cronEvery" label="1">{{ text.Day.every }}</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="day.cronEvery" label="2">{{ text.Day.intervalDay[0] }}
|
||||
<el-input-number v-model="day.incrementIncrement" size="small" :min="1" :max="31" />
|
||||
{{ text.Day.intervalDay[1] }}
|
||||
<el-input-number v-model="day.incrementStart" size="small" :min="1" :max="31" />
|
||||
{{ text.Day.intervalDay[2] }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="day.cronEvery" class="long" label="3">{{ text.Day.specificDay }}
|
||||
<el-select v-model="day.specificSpecific" size="small" multiple>
|
||||
<el-option v-for="val in 31" :key="val" :value="val.toString()" :label="val" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="day.cronEvery" label="4">{{ text.Day.cycle[0] }}
|
||||
<el-input-number v-model="day.rangeStart" size="small" :min="1" :max="31" />
|
||||
{{ text.Day.cycle[1] }}
|
||||
<el-input-number v-model="day.rangeEnd" size="small" :min="1" :max="31" />
|
||||
</el-radio>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane>
|
||||
<span slot="label"><i class="el-icon-date" /> {{ text.Month.name }}</span>
|
||||
<div class="tabBody">
|
||||
<el-row>
|
||||
<el-radio v-model="month.cronEvery" label="1">{{ text.Month.every }}</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="month.cronEvery" label="2">{{ text.Month.interval[0] }}
|
||||
<el-input-number v-model="month.incrementIncrement" size="small" :min="0" :max="12" />
|
||||
{{ text.Month.interval[1] }}
|
||||
<el-input-number v-model="month.incrementStart" size="small" :min="0" :max="12" />
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="month.cronEvery" class="long" label="3">{{ text.Month.specific }}
|
||||
<el-select v-model="month.specificSpecific" size="small" multiple>
|
||||
<el-option v-for="val in 12" :key="val" :label="val" :value="val.toString()" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="month.cronEvery" label="4">{{ text.Month.cycle[0] }}
|
||||
<el-input-number v-model="month.rangeStart" size="small" :min="1" :max="12" />
|
||||
{{ text.Month.cycle[1] }}
|
||||
<el-input-number v-model="month.rangeEnd" size="small" :min="1" :max="12" />
|
||||
{{ text.Month.cycle[2] }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane>
|
||||
<span slot="label"><i class="el-icon-date" /> {{ text.Week.name }}</span>
|
||||
<div class="tabBody">
|
||||
<el-row>
|
||||
<el-radio v-model="week.cronEvery" label="1">{{ text.Week.every }}</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="week.cronEvery" class="long" label="3">{{ text.Week.specific }}
|
||||
<el-select v-model="week.specificSpecific" size="small" multiple>
|
||||
<el-option v-for="i in 7" :key="i" :label="text.Week.list[i - 1]" :value="i.toString()" />
|
||||
</el-select>
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-radio v-model="week.cronEvery" label="4">{{ text.Week.cycle[0] }}
|
||||
<el-input-number v-model="week.rangeStart" size="small" :min="1" :max="7" />
|
||||
{{ text.Week.cycle[1] }}
|
||||
<el-input-number v-model="week.rangeEnd" size="small" :min="1" :max="7" />
|
||||
</el-radio>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Language from './language/index'
|
||||
|
||||
export default {
|
||||
name: 'VueCronLinux',
|
||||
props: ['data', 'i18n'],
|
||||
data() {
|
||||
return {
|
||||
second: {
|
||||
cronEvery: '',
|
||||
incrementStart: '3',
|
||||
incrementIncrement: '5',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
specificSpecific: [0]
|
||||
},
|
||||
minute: {
|
||||
cronEvery: '',
|
||||
incrementStart: '3',
|
||||
incrementIncrement: '5',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
specificSpecific: ['0']
|
||||
},
|
||||
hour: {
|
||||
cronEvery: '',
|
||||
incrementStart: '3',
|
||||
incrementIncrement: '5',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
specificSpecific: ['0']
|
||||
},
|
||||
day: {
|
||||
cronEvery: '',
|
||||
incrementStart: '1',
|
||||
incrementIncrement: '1',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
specificSpecific: ['1'],
|
||||
cronLastSpecificDomDay: 1,
|
||||
cronDaysBeforeEomMinus: '',
|
||||
cronDaysNearestWeekday: ''
|
||||
},
|
||||
month: {
|
||||
cronEvery: '',
|
||||
incrementStart: '3',
|
||||
incrementIncrement: '5',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
specificSpecific: ['1']
|
||||
},
|
||||
week: {
|
||||
cronEvery: '',
|
||||
incrementStart: '1',
|
||||
incrementIncrement: '1',
|
||||
specificSpecific: ['1'],
|
||||
cronNthDayDay: 1,
|
||||
cronNthDayNth: '1',
|
||||
rangeStart: '',
|
||||
rangeEnd: ''
|
||||
},
|
||||
output: {
|
||||
second: '',
|
||||
minute: '',
|
||||
hour: '',
|
||||
day: '',
|
||||
month: '',
|
||||
Week: '',
|
||||
year: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
return Language[this.i18n || 'cn']
|
||||
},
|
||||
minutesText() {
|
||||
let minutes = ''
|
||||
const cronEvery = this.minute.cronEvery
|
||||
switch (cronEvery.toString()) {
|
||||
case '1':
|
||||
minutes = '*'
|
||||
break
|
||||
case '2':
|
||||
minutes = this.minute.incrementStart + '/' + this.minute.incrementIncrement
|
||||
break
|
||||
case '3':
|
||||
this.minute.specificSpecific.map(val => {
|
||||
minutes += val + ','
|
||||
})
|
||||
minutes = minutes.slice(0, -1)
|
||||
break
|
||||
case '4':
|
||||
minutes = this.minute.rangeStart + '-' + this.minute.rangeEnd
|
||||
break
|
||||
}
|
||||
return minutes
|
||||
},
|
||||
hoursText() {
|
||||
let hours = ''
|
||||
const cronEvery = this.hour.cronEvery
|
||||
switch (cronEvery.toString()) {
|
||||
case '1':
|
||||
hours = '*'
|
||||
break
|
||||
case '2':
|
||||
hours = this.hour.incrementStart + '/' + this.hour.incrementIncrement
|
||||
break
|
||||
case '3':
|
||||
this.hour.specificSpecific.map(val => {
|
||||
hours += val + ','
|
||||
})
|
||||
hours = hours.slice(0, -1)
|
||||
break
|
||||
case '4':
|
||||
hours = this.hour.rangeStart + '-' + this.hour.rangeEnd
|
||||
break
|
||||
}
|
||||
return hours
|
||||
},
|
||||
daysText() {
|
||||
let days = ''
|
||||
const cronEvery = this.day.cronEvery
|
||||
switch (cronEvery.toString()) {
|
||||
case '1':
|
||||
days = '*'
|
||||
break
|
||||
case '2':
|
||||
days = this.day.incrementStart + '/' + this.day.incrementIncrement
|
||||
break
|
||||
case '3':
|
||||
this.day.specificSpecific.map(val => {
|
||||
days += val + ','
|
||||
})
|
||||
days = days.slice(0, -1)
|
||||
break
|
||||
case '4':
|
||||
days = this.day.rangeStart + '-' + this.day.rangeEnd
|
||||
break
|
||||
}
|
||||
return days
|
||||
},
|
||||
monthsText() {
|
||||
let months = ''
|
||||
const cronEvery = this.month.cronEvery
|
||||
switch (cronEvery.toString()) {
|
||||
case '1':
|
||||
months = '*'
|
||||
break
|
||||
case '2':
|
||||
months = this.month.incrementStart + '/' + this.month.incrementIncrement
|
||||
break
|
||||
case '3':
|
||||
this.month.specificSpecific.map(val => {
|
||||
months += val + ','
|
||||
})
|
||||
months = months.slice(0, -1)
|
||||
break
|
||||
case '4':
|
||||
months = this.month.rangeStart + '-' + this.month.rangeEnd
|
||||
break
|
||||
}
|
||||
return months
|
||||
},
|
||||
weeksText() {
|
||||
let weeks = ''
|
||||
const cronEvery = this.week.cronEvery
|
||||
switch (cronEvery.toString()) {
|
||||
case '1':
|
||||
weeks = '*'
|
||||
break
|
||||
case '3':
|
||||
this.week.specificSpecific.map(val => {
|
||||
weeks += val + ','
|
||||
})
|
||||
weeks = weeks.slice(0, -1)
|
||||
break
|
||||
case '4':
|
||||
weeks = this.week.rangeStart + '-' + this.week.rangeEnd
|
||||
break
|
||||
}
|
||||
return weeks
|
||||
},
|
||||
cron() {
|
||||
return [this.minutesText, this.hoursText, this.daysText, this.monthsText, this.weeksText]
|
||||
.filter(v => !!v)
|
||||
.join(' ')
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data() {
|
||||
this.updateCronFromData()
|
||||
},
|
||||
cron() {
|
||||
this.$emit('change', this.cron)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateCronFromData()
|
||||
},
|
||||
methods: {
|
||||
change() {
|
||||
this.$emit('change', this.cron)
|
||||
this.close()
|
||||
},
|
||||
close() {
|
||||
this.$emit('close')
|
||||
},
|
||||
submit() {
|
||||
if (!this.validate()) {
|
||||
this.$message.error(this.$t('Cron expression is invalid'))
|
||||
return false
|
||||
}
|
||||
this.$emit('submit', this.cron)
|
||||
return true
|
||||
},
|
||||
validate() {
|
||||
if (!this.minutesText) return false
|
||||
if (!this.hoursText) return false
|
||||
if (!this.daysText) return false
|
||||
if (!this.monthsText) return false
|
||||
if (!this.weeksText) return false
|
||||
return true
|
||||
},
|
||||
updateCronItem(key, value) {
|
||||
if (value === undefined) {
|
||||
this[key].cronEvery = '0'
|
||||
return
|
||||
}
|
||||
if (value.match(/^\*$/)) {
|
||||
this[key].cronEvery = '1'
|
||||
} else if (value.match(/\//)) {
|
||||
this[key].cronEvery = '2'
|
||||
this[key].incrementStart = value.split('/')[0]
|
||||
this[key].incrementIncrement = value.split('/')[1]
|
||||
} else if (value.match(/,|^\d+$/)) {
|
||||
this[key].cronEvery = '3'
|
||||
this[key].specificSpecific = value.split(',')
|
||||
} else if (value.match(/-/)) {
|
||||
this[key].cronEvery = '4'
|
||||
this[key].rangeStart = value.split('-')[0]
|
||||
this[key].rangeEnd = value.split('-')[1]
|
||||
} else {
|
||||
this[key].cronEvery = '0'
|
||||
}
|
||||
},
|
||||
updateCronFromData() {
|
||||
const arr = this.data.split(' ')
|
||||
const minute = arr[0]
|
||||
const hour = arr[1]
|
||||
const day = arr[2]
|
||||
const month = arr[3]
|
||||
const week = arr[4]
|
||||
|
||||
this.updateCronItem('minute', minute)
|
||||
this.updateCronItem('hour', hour)
|
||||
this.updateCronItem('day', day)
|
||||
this.updateCronItem('month', month)
|
||||
this.updateCronItem('week', week)
|
||||
}
|
||||
}
|
||||
}</script>
|
||||
@@ -1,61 +0,0 @@
|
||||
export default {
|
||||
Seconds: {
|
||||
name: '秒',
|
||||
every: '每一秒钟',
|
||||
interval: ['每隔', '秒执行 从', '秒开始'],
|
||||
specific: '具体秒数(可多选)',
|
||||
cycle: ['周期从', '到', '秒']
|
||||
},
|
||||
Minutes: {
|
||||
name: '分',
|
||||
every: '每一分钟',
|
||||
interval: ['每隔', '分执行 从', '分开始'],
|
||||
specific: '具体分钟数(可多选)',
|
||||
cycle: ['周期从', '到', '分']
|
||||
},
|
||||
Hours: {
|
||||
name: '时',
|
||||
every: '每一小时',
|
||||
interval: ['每隔', '小时执行 从', '小时开始'],
|
||||
specific: '具体小时数(可多选)',
|
||||
cycle: ['周期从', '到', '小时']
|
||||
},
|
||||
Day: {
|
||||
name: '天',
|
||||
every: '每一天',
|
||||
intervalWeek: ['每隔', '周执行 从', '开始'],
|
||||
intervalDay: ['每隔', '天执行 从', '天开始'],
|
||||
specificWeek: '具体星期几(可多选)',
|
||||
specificDay: '具体天数(可多选)',
|
||||
lastDay: '在这个月的最后一天',
|
||||
lastWeekday: '在这个月的最后一个工作日',
|
||||
lastWeek: ['在这个月的最后一个'],
|
||||
beforeEndMonth: ['在本月底前', '天'],
|
||||
nearestWeekday: ['最近的工作日(周一至周五)至本月', '日'],
|
||||
someWeekday: ['在这个月的第', '个'],
|
||||
cycle: ['从', '到']
|
||||
},
|
||||
Week: {
|
||||
name: '周',
|
||||
every: '每天',
|
||||
specific: '具体天数(可多选)',
|
||||
list: ['一', '二', '三', '四', '五', '六', '天'].map(val => '星期' + val),
|
||||
cycle: ['从', '到']
|
||||
},
|
||||
Month: {
|
||||
name: '月',
|
||||
every: '每一月',
|
||||
interval: ['每隔', '月执行 从', '月开始'],
|
||||
specific: '具体月数(可多选)',
|
||||
cycle: ['从', '到', '月之间的每个月']
|
||||
},
|
||||
Year: {
|
||||
name: '年',
|
||||
every: '每一年',
|
||||
interval: ['每隔', '年执行 从', '年开始'],
|
||||
specific: '具体年份(可多选)',
|
||||
cycle: ['从', '到', '年之间的每一年']
|
||||
},
|
||||
Save: '保存',
|
||||
Close: '关闭'
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
export default {
|
||||
Seconds: {
|
||||
name: 'Seconds',
|
||||
every: 'Every second',
|
||||
interval: ['Every', 'second(s) starting at second'],
|
||||
specific: 'Specific second (choose one or many)',
|
||||
cycle: ['Every second between second', 'and second']
|
||||
},
|
||||
Minutes: {
|
||||
name: 'Minutes',
|
||||
every: 'Every minute',
|
||||
interval: ['Every', 'minute(s) starting at minute'],
|
||||
specific: 'Specific minute (choose one or many)',
|
||||
cycle: ['Every minute between minute', 'and minute']
|
||||
},
|
||||
Hours: {
|
||||
name: 'Hours',
|
||||
every: 'Every hour',
|
||||
interval: ['Every', 'hour(s) starting at hour'],
|
||||
specific: 'Specific hour (choose one or many)',
|
||||
cycle: ['Every hour between hour', 'and hour']
|
||||
},
|
||||
Day: {
|
||||
name: 'Day',
|
||||
every: 'Every day',
|
||||
intervalWeek: ['Every', 'day(s) starting on'],
|
||||
intervalDay: ['Every', 'day(s) starting at the', 'of the month'],
|
||||
specificWeek: 'Specific day of week (choose one or many)',
|
||||
specificDay: 'Specific day of month (choose one or many)',
|
||||
lastDay: 'On the last day of the month',
|
||||
lastWeekday: 'On the last weekday of the month',
|
||||
lastWeek: ['On the last', ' of the month'],
|
||||
beforeEndMonth: ['day(s) before the end of the month'],
|
||||
nearestWeekday: [
|
||||
'Nearest weekday (Monday to Friday) to the',
|
||||
'of the month'],
|
||||
someWeekday: ['On the', 'of the month'],
|
||||
cycle: ['From', 'to']
|
||||
},
|
||||
Week: {
|
||||
name: 'Week',
|
||||
every: 'Every day',
|
||||
specific: 'Specific weekday (choose on or many)',
|
||||
list: [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday'],
|
||||
cycle: ['From', 'to']
|
||||
},
|
||||
// Week:['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
|
||||
Month: {
|
||||
name: 'Month',
|
||||
every: 'Every month',
|
||||
interval: ['Every', 'month(s) starting in'],
|
||||
specific: 'Specific month (choose one or many)',
|
||||
cycle: ['Every month between', 'and']
|
||||
},
|
||||
Year: {
|
||||
name: 'Year',
|
||||
every: 'Any year',
|
||||
interval: ['Every', 'year(s) starting in'],
|
||||
specific: 'Specific year (choose one or many)',
|
||||
cycle: ['Every year between', 'and']
|
||||
},
|
||||
Save: 'Save',
|
||||
Close: 'Close'
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import en from './en'
|
||||
import cn from './cn'
|
||||
|
||||
export default {
|
||||
en,
|
||||
cn
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<el-tree
|
||||
ref="documentation-tree"
|
||||
:data="docData"
|
||||
node-key="fullUrl"
|
||||
>
|
||||
<span
|
||||
slot-scope="{ node, data }"
|
||||
class="custom-tree-node"
|
||||
:class="[data.active ? 'active' : '', `level-${data.level}`]"
|
||||
>
|
||||
<template v-if="data.level === 1 && data.children && data.children.length">
|
||||
<span>{{ node.label }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>
|
||||
<a :href="data.fullUrl" target="_blank" style="display: block" @click="onClickDocumentationLink">
|
||||
{{ node.label }}
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</el-tree>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'Documentation',
|
||||
data() {
|
||||
return {
|
||||
data: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('doc', [
|
||||
'docData'
|
||||
]),
|
||||
pathLv1() {
|
||||
if (this.$route.path === '/') return '/'
|
||||
const m = this.$route.path.match(/(^\/\w+)/)
|
||||
return m[1]
|
||||
},
|
||||
currentDoc() {
|
||||
// find current doc
|
||||
let currentDoc
|
||||
for (let i = 0; i < this.$utils.doc.docs.length; i++) {
|
||||
const doc = this.$utils.doc.docs[i]
|
||||
if (this.pathLv1 === doc.path) {
|
||||
currentDoc = doc
|
||||
break
|
||||
}
|
||||
}
|
||||
return currentDoc
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
pathLv1() {
|
||||
this.update()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
},
|
||||
mounted() {
|
||||
this.update()
|
||||
},
|
||||
methods: {
|
||||
isActiveNode(d) {
|
||||
// check match
|
||||
if (!this.currentDoc) return false
|
||||
return !!d.url.match(this.currentDoc.pattern)
|
||||
},
|
||||
update() {
|
||||
// expand related documentation list
|
||||
setTimeout(() => {
|
||||
this.docData.forEach(d => {
|
||||
// parent node
|
||||
const isActive = this.isActiveNode(d)
|
||||
const node = this.$refs['documentation-tree'].getNode(d)
|
||||
node.expanded = isActive
|
||||
this.$set(d, 'active', isActive)
|
||||
|
||||
// child nodes
|
||||
d.children.forEach(c => {
|
||||
const node = this.$refs['documentation-tree'].getNode(c)
|
||||
const isActive = this.isActiveNode(c)
|
||||
if (!node.parent.expanded && isActive) {
|
||||
node.parent.expanded = true
|
||||
}
|
||||
this.$set(c, 'active', isActive)
|
||||
})
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
async getDocumentationData() {
|
||||
// fetch api data
|
||||
await this.$store.dispatch('doc/getDocData')
|
||||
},
|
||||
onClickDocumentationLink() {
|
||||
this.$st.sendEv('全局', '点击右侧文档链接')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.el-tree >>> .custom-tree-node.active {
|
||||
color: #409eff;
|
||||
/*text-decoration: underline;*/
|
||||
}
|
||||
|
||||
.el-tree >>> .custom-tree-node.level-1 {
|
||||
font-weight: bolder;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div class="environment-list">
|
||||
<el-row>
|
||||
<div class="button-group">
|
||||
<el-button size="small" type="primary" icon="el-icon-plus" @click="addEnv">{{ $t('Add Environment Variables') }}</el-button>
|
||||
<el-button size="small" type="success" @click="save">{{ $t('Save') }}</el-button>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-table :data="spiderForm.envs">
|
||||
<el-table-column :label="$t('Variable')">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.name" :placeholder="$t('Variable')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Value')">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.value" :placeholder="$t('Value')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Action')">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" icon="el-icon-delete" type="danger" @click="deleteEnv(scope.$index)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'EnvironmentList',
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
addEnv() {
|
||||
if (!this.spiderForm.envs) {
|
||||
this.$set(this.spiderForm, 'envs', [])
|
||||
}
|
||||
this.spiderForm.envs.push({
|
||||
name: '',
|
||||
value: ''
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', '环境', '添加')
|
||||
},
|
||||
deleteEnv(index) {
|
||||
this.spiderForm.envs.splice(index, 1)
|
||||
this.$st.sendEv('爬虫详情', '环境', '删除')
|
||||
},
|
||||
save() {
|
||||
this.$store.dispatch('spider/editSpider')
|
||||
.then(() => {
|
||||
this.$message.success(this.$t('Spider info has been saved successfully'))
|
||||
})
|
||||
.catch(error => {
|
||||
this.$message.error(error)
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', '环境', '保存')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-group {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
min-height: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div class="file-detail">
|
||||
<codemirror
|
||||
v-model="fileContent"
|
||||
class="file-content"
|
||||
:options="options"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState,
|
||||
mapGetters
|
||||
} from 'vuex'
|
||||
import { codemirror } from 'vue-codemirror-lite'
|
||||
|
||||
import 'codemirror/lib/codemirror.js'
|
||||
|
||||
// language
|
||||
import 'codemirror/mode/python/python.js'
|
||||
import 'codemirror/mode/javascript/javascript.js'
|
||||
import 'codemirror/mode/go/go.js'
|
||||
import 'codemirror/mode/shell/shell.js'
|
||||
import 'codemirror/mode/markdown/markdown.js'
|
||||
import 'codemirror/mode/php/php.js'
|
||||
import 'codemirror/mode/yaml/yaml.js'
|
||||
|
||||
export default {
|
||||
name: 'FileDetail',
|
||||
components: { codemirror },
|
||||
data() {
|
||||
return {
|
||||
internalFileContent: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapGetters('user', [
|
||||
'userInfo'
|
||||
]),
|
||||
fileContent: {
|
||||
get() {
|
||||
return this.$store.state.file.fileContent
|
||||
},
|
||||
set(value) {
|
||||
return this.$store.commit('file/SET_FILE_CONTENT', value)
|
||||
}
|
||||
},
|
||||
options() {
|
||||
return {
|
||||
mode: this.language,
|
||||
theme: 'darcula',
|
||||
styleActiveLine: true,
|
||||
smartIndent: true,
|
||||
indentUnit: 4,
|
||||
lineNumbers: true,
|
||||
line: true,
|
||||
matchBrackets: true,
|
||||
readOnly: this.isDisabled ? 'nocursor' : false
|
||||
}
|
||||
},
|
||||
language() {
|
||||
const fileName = this.$store.state.file.currentPath
|
||||
if (!fileName) return ''
|
||||
if (fileName.match(/\.js$/)) {
|
||||
return 'text/javascript'
|
||||
} else if (fileName.match(/\.py$/)) {
|
||||
return 'text/x-python'
|
||||
} else if (fileName.match(/\.go$/)) {
|
||||
return 'text/x-go'
|
||||
} else if (fileName.match(/\.sh$/)) {
|
||||
return 'text/x-shell'
|
||||
} else if (fileName.match(/\.php$/)) {
|
||||
return 'text/x-php'
|
||||
} else if (fileName.match(/\.md$/)) {
|
||||
return 'text/x-markdown'
|
||||
} else if (fileName.match('Spiderfile')) {
|
||||
return 'text/x-yaml'
|
||||
} else {
|
||||
return 'text'
|
||||
}
|
||||
},
|
||||
isDisabled() {
|
||||
return this.spiderForm.is_public && this.spiderForm.username !== this.userInfo.username && this.userInfo.role !== 'admin'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.internalFileContent = this.fileContent
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-content {
|
||||
border: 1px solid #eaecef;
|
||||
height: calc(100vh - 256px);
|
||||
}
|
||||
|
||||
.file-content >>> .CodeMirror {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
823
frontend/src/components/File/FileEditor.vue
Normal file
@@ -0,0 +1,823 @@
|
||||
<template>
|
||||
<div ref="fileEditor" class="file-editor">
|
||||
<div :class="navMenuCollapsed ? 'collapsed' : ''" class="nav-menu">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColorGutters,
|
||||
color: style.color,
|
||||
}"
|
||||
class="nav-menu-top-bar"
|
||||
>
|
||||
<div class="left">
|
||||
<el-input
|
||||
v-model="fileSearchString"
|
||||
:style="{
|
||||
color: style.color,
|
||||
}"
|
||||
class="search"
|
||||
clearable
|
||||
placeholder="Search files..."
|
||||
prefix-icon="fa fa-search"
|
||||
size="mini"
|
||||
/>
|
||||
</div>
|
||||
<div class="right">
|
||||
<el-tooltip content="Settings">
|
||||
<span class="action-icon" @click="showSettings = true">
|
||||
<div class="background"/>
|
||||
<font-awesome-icon :icon="['fa', 'cog']"/>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="Hide files">
|
||||
<span class="action-icon" @click="onToggleNavMenu">
|
||||
<div class="background"/>
|
||||
<font-awesome-icon :icon="['fa', 'minus']"/>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<FileEditorNavMenu
|
||||
:active-item="activeFileItem"
|
||||
:default-expand-all="!!fileSearchString"
|
||||
:items="files"
|
||||
:style="style"
|
||||
@node-click="onNavItemClick"
|
||||
@node-db-click="onNavItemDbClick"
|
||||
@node-drop="onNavItemDrop"
|
||||
@ctx-menu-new-file="onContextMenuNewFile"
|
||||
@ctx-menu-new-directory="onContextMenuNewDirectory"
|
||||
@ctx-menu-rename="onContextMenuRename"
|
||||
@ctx-menu-clone="onContextMenuClone"
|
||||
@ctx-menu-delete="onContextMenuDelete"
|
||||
@drop-files="onDropFiles"
|
||||
/>
|
||||
</div>
|
||||
<div class="file-editor-content">
|
||||
<FileEditorNavTabs
|
||||
ref="navTabs"
|
||||
:active-tab="activeFileItem"
|
||||
:tabs="tabs"
|
||||
:style="style"
|
||||
@tab-click="onTabClick"
|
||||
@tab-close="onTabClose"
|
||||
@tab-close-others="onTabCloseOthers"
|
||||
@tab-close-all="onTabCloseAll"
|
||||
@tab-dragend="onTabDragEnd"
|
||||
>
|
||||
<template v-if="navMenuCollapsed" #prefix>
|
||||
<el-tooltip content="Show files">
|
||||
<span class="action-icon expand-files" @click="onToggleNavMenu">
|
||||
<div class="background"/>
|
||||
<font-awesome-icon :icon="['fa', 'bars']"/>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</FileEditorNavTabs>
|
||||
<div
|
||||
ref="codeMirrorEditor"
|
||||
:class="showCodeMirrorEditor ? '' : 'hidden'"
|
||||
:style="{
|
||||
scrollbar: style.backgroundColorGutters,
|
||||
}"
|
||||
class="code-mirror-editor"
|
||||
/>
|
||||
<div
|
||||
v-show="!showCodeMirrorEditor"
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
}"
|
||||
class="empty-content"
|
||||
>
|
||||
You can edit or view a file by double-clicking one of the files on the left.
|
||||
</div>
|
||||
<template v-if="navTabs && navTabs.showMoreVisible">
|
||||
<FileEditorNavTabsShowMoreContextMenu
|
||||
:tabs="tabs"
|
||||
:visible="showMoreContextMenuVisible"
|
||||
@hide="onShowMoreHide"
|
||||
@tab-click="onClickShowMoreContextMenuItem"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
background: style.backgroundColor,
|
||||
color: style.color,
|
||||
}"
|
||||
class="nav-tabs-suffix"
|
||||
>
|
||||
<el-tooltip content="Show more">
|
||||
<span class="action-icon" @click.prevent="onShowMoreShow">
|
||||
<div class="background"/>
|
||||
<font-awesome-icon :icon="['fa', 'angle-down']"/>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</FileEditorNavTabsShowMoreContextMenu>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="codeMirrorTemplate" class="code-mirror-template"/>
|
||||
<div ref="styleRef" v-html="extraStyle"/>
|
||||
<FileEditorSettingsDialog/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch} from 'vue';
|
||||
import CodeMirror, {Editor, EditorConfiguration, KeyMap} from 'codemirror';
|
||||
import {MimeType} from 'codemirror/mode/meta';
|
||||
import {useStore} from 'vuex';
|
||||
import {getCodemirrorEditor, getCodeMirrorTemplate, initTheme} from '@/utils/codemirror';
|
||||
import variables from '@/styles/variables.scss';
|
||||
import {FILE_ROOT} from '@/constants/file';
|
||||
|
||||
// codemirror css
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
|
||||
// codemirror mode
|
||||
import 'codemirror/mode/meta';
|
||||
|
||||
// codemirror utils
|
||||
import '@/utils/codemirror';
|
||||
|
||||
// components
|
||||
import FileEditorNavMenu from '@/components/file/FileEditorNavMenu.vue';
|
||||
import FileEditorNavTabs from '@/components/file/FileEditorNavTabs.vue';
|
||||
import FileEditorSettingsDialog from '@/components/file/FileEditorSettingsDialog.vue';
|
||||
import FileEditorNavTabsShowMoreContextMenu from '@/components/file/FileEditorNavTabsShowMoreContextMenu.vue';
|
||||
|
||||
// codemirror mode import cache
|
||||
const codeMirrorModeCache = new Set<string>();
|
||||
|
||||
// codemirror tab content cache
|
||||
const codeMirrorTabContentCache = new Map<string, string>();
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditor',
|
||||
components: {
|
||||
FileEditorSettingsDialog,
|
||||
FileEditorNavTabs,
|
||||
FileEditorNavMenu,
|
||||
FileEditorNavTabsShowMoreContextMenu,
|
||||
},
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
activeNavItem: {
|
||||
type: Object as PropType<FileNavItem>,
|
||||
required: false,
|
||||
},
|
||||
navItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'content-change',
|
||||
'tab-click',
|
||||
'node-click',
|
||||
'node-db-click',
|
||||
'node-drop',
|
||||
'save-file',
|
||||
'ctx-menu-new-file',
|
||||
'ctx-menu-new-directory',
|
||||
'ctx-menu-rename',
|
||||
'ctx-menu-clone',
|
||||
'ctx-menu-delete',
|
||||
'drop-files',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const ns = 'spider';
|
||||
const store = useStore();
|
||||
const {file} = store.state as RootStoreState;
|
||||
|
||||
const fileEditor = ref<HTMLDivElement>();
|
||||
|
||||
const codeMirrorEditor = ref<HTMLDivElement>();
|
||||
|
||||
const tabs = ref<FileNavItem[]>([]);
|
||||
|
||||
const activeFileItem = computed<FileNavItem | undefined>(() => props.activeNavItem);
|
||||
|
||||
const style = ref<FileEditorStyle>({});
|
||||
|
||||
const fileSearchString = ref<string>('');
|
||||
|
||||
const navMenuCollapsed = ref<boolean>(false);
|
||||
|
||||
const showSettings = ref<boolean>(false);
|
||||
|
||||
const styleRef = ref<HTMLDivElement>();
|
||||
|
||||
let editor: Editor | null = null;
|
||||
|
||||
let codeMirrorTemplateEditor: Editor | null = null;
|
||||
|
||||
const codeMirrorTemplate = ref<HTMLDivElement>();
|
||||
|
||||
let codeMirrorEditorSearchLabel: HTMLSpanElement | undefined;
|
||||
|
||||
let codeMirrorEditorSearchInput: HTMLInputElement | undefined;
|
||||
|
||||
const navTabs = ref<typeof FileEditorNavTabs>();
|
||||
|
||||
const showMoreContextMenuVisible = ref<boolean>(false);
|
||||
|
||||
const showCodeMirrorEditor = computed<boolean>(() => {
|
||||
return !!activeFileItem.value;
|
||||
});
|
||||
|
||||
const language = computed<MimeType | undefined>(() => {
|
||||
const fileName = activeFileItem.value?.name;
|
||||
if (!fileName) return;
|
||||
return CodeMirror.findModeByFileName(fileName);
|
||||
});
|
||||
|
||||
const languageMime = computed<string | undefined>(() => language.value?.mime);
|
||||
|
||||
const options = computed<FileEditorConfiguration>(() => {
|
||||
const {editorOptions} = file as FileStoreState;
|
||||
return {
|
||||
mode: languageMime.value || 'text',
|
||||
...editorOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const content = computed<string>(() => {
|
||||
const {content} = props as FileEditorProps;
|
||||
return content || '';
|
||||
});
|
||||
|
||||
const extraStyle = computed<string>(() => {
|
||||
return `<style>
|
||||
.file-editor .file-editor-nav-menu::-webkit-scrollbar {
|
||||
background-color: ${style.value.backgroundColor};
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.file-editor .file-editor-nav-menu::-webkit-scrollbar-thumb {
|
||||
background-color: ${variables.primaryColor};
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-vscrollbar::-webkit-scrollbar {
|
||||
background-color: ${style.value.backgroundColor};
|
||||
width: 8px;
|
||||
}
|
||||
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-hscrollbar::-webkit-scrollbar {
|
||||
background-color: ${style.value.backgroundColor};
|
||||
height: 8px;
|
||||
}
|
||||
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-vscrollbar::-webkit-scrollbar-thumb,
|
||||
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-hscrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: ${variables.primaryColor};
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-editor .file-editor-nav-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>`;
|
||||
});
|
||||
|
||||
const codeMirrorTemplateContent = computed<string>(() => {
|
||||
return getCodeMirrorTemplate();
|
||||
});
|
||||
|
||||
const updateEditorOptions = () => {
|
||||
for (const k in options.value) {
|
||||
const key = k as keyof EditorConfiguration;
|
||||
const value = options.value[key];
|
||||
editor?.setOption(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEditorContent = () => {
|
||||
editor?.setValue(content.value || '');
|
||||
};
|
||||
|
||||
const updateStyle = () => {
|
||||
// codemirror style: background color / color / height
|
||||
const el = codeMirrorEditor.value as HTMLElement;
|
||||
const cm = el.querySelector('.CodeMirror');
|
||||
if (!cm) return;
|
||||
const computedStyle = window.getComputedStyle(cm);
|
||||
style.value = {
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
color: computedStyle.color,
|
||||
height: computedStyle.height,
|
||||
};
|
||||
|
||||
// gutter
|
||||
const cmGutters = el.querySelector('.CodeMirror-gutters');
|
||||
if (!cmGutters) return;
|
||||
const computedStyleGutters = window.getComputedStyle(cmGutters);
|
||||
style.value.backgroundColorGutters = computedStyleGutters.backgroundColor;
|
||||
};
|
||||
|
||||
const updateTheme = async () => {
|
||||
await initTheme(options.value.theme);
|
||||
};
|
||||
|
||||
const updateMode = async () => {
|
||||
const mode = language.value?.mode;
|
||||
if (!mode || codeMirrorModeCache.has(mode)) return;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
await import(`codemirror/mode/${mode}/${mode}.js`);
|
||||
codeMirrorModeCache.add(mode);
|
||||
};
|
||||
|
||||
const updateSearchInput = () => {
|
||||
codeMirrorEditorSearchLabel = codeMirrorEditor.value?.querySelector<HTMLSpanElement>('.CodeMirror-search-label') || undefined;
|
||||
codeMirrorEditorSearchInput = codeMirrorEditor.value?.querySelector<HTMLInputElement>('.CodeMirror-search-field') || undefined;
|
||||
if (!codeMirrorEditorSearchInput) return;
|
||||
codeMirrorEditorSearchInput.onblur = () => {
|
||||
if (codeMirrorEditorSearchLabel?.textContent?.includes('Search')) {
|
||||
setTimeout(() => {
|
||||
codeMirrorEditorSearchInput?.parentElement?.remove();
|
||||
editor?.focus();
|
||||
}, 10);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getContentCache = (tab: FileNavItem) => {
|
||||
if (!tab.path) return;
|
||||
const key = tab.path;
|
||||
const content = codeMirrorTabContentCache.get(key);
|
||||
emit('content-change', content as string);
|
||||
setTimeout(updateEditorContent, 0);
|
||||
};
|
||||
|
||||
const updateContentCache = (tab: FileNavItem, content: string) => {
|
||||
if (!tab.path) return;
|
||||
const key = tab.path as string;
|
||||
codeMirrorTabContentCache.set(key, content as string);
|
||||
};
|
||||
|
||||
const deleteContentCache = (tab: FileNavItem) => {
|
||||
if (!tab.path) return;
|
||||
const key = tab.path;
|
||||
codeMirrorTabContentCache.delete(key);
|
||||
};
|
||||
|
||||
const deleteOtherContentCache = (tab: FileNavItem) => {
|
||||
if (!tab.path) return;
|
||||
const key = tab.path;
|
||||
const content = codeMirrorTabContentCache.get(key);
|
||||
codeMirrorTabContentCache.clear();
|
||||
codeMirrorTabContentCache.set(key, content as string);
|
||||
};
|
||||
|
||||
const clearContentCache = () => {
|
||||
codeMirrorTabContentCache.clear();
|
||||
};
|
||||
|
||||
const getFilteredFiles = (items: FileNavItem[]): FileNavItem[] => {
|
||||
return items
|
||||
.filter(d => {
|
||||
if (!d.is_dir) {
|
||||
return d.name?.toLowerCase().includes(fileSearchString.value.toLowerCase());
|
||||
}
|
||||
if (d.children) {
|
||||
const children = getFilteredFiles(d.children);
|
||||
if (children.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(d => {
|
||||
if (!d.is_dir) return d;
|
||||
d.children = getFilteredFiles(d.children || []);
|
||||
return d;
|
||||
});
|
||||
};
|
||||
|
||||
const files = computed<FileNavItem[]>(() => {
|
||||
const {navItems} = props as FileEditorProps;
|
||||
const root: FileNavItem = {
|
||||
path: FILE_ROOT,
|
||||
name: FILE_ROOT,
|
||||
is_dir: true,
|
||||
children: fileSearchString.value ? getFilteredFiles(navItems) : navItems,
|
||||
};
|
||||
return [root];
|
||||
});
|
||||
|
||||
const updateTabs = (item?: FileNavItem) => {
|
||||
// add tab
|
||||
if (item && !tabs.value.find(t => t.path === item.path)) {
|
||||
if (tabs.value.length === 0) {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, item);
|
||||
editor?.focus();
|
||||
}
|
||||
tabs.value.push(item);
|
||||
getContentCache(item);
|
||||
}
|
||||
};
|
||||
|
||||
const onNavItemClick = (item: FileNavItem) => {
|
||||
emit('node-click', item);
|
||||
};
|
||||
|
||||
const onNavItemDbClick = (item: FileNavItem) => {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, item);
|
||||
emit('node-db-click', item);
|
||||
|
||||
// update tabs
|
||||
updateTabs(item);
|
||||
};
|
||||
|
||||
const onNavItemDrop = (draggingItem: FileNavItem, dropItem: FileNavItem) => {
|
||||
emit('node-drop', draggingItem, dropItem);
|
||||
};
|
||||
|
||||
const onContextMenuNewFile = (item: FileNavItem, name: string) => {
|
||||
emit('ctx-menu-new-file', item, name);
|
||||
};
|
||||
|
||||
const onContextMenuNewDirectory = (item: FileNavItem, name: string) => {
|
||||
emit('ctx-menu-new-directory', item, name);
|
||||
};
|
||||
|
||||
const onContextMenuRename = (item: FileNavItem, name: string) => {
|
||||
emit('ctx-menu-rename', item, name);
|
||||
};
|
||||
|
||||
const onContextMenuClone = (item: FileNavItem, name: string) => {
|
||||
emit('ctx-menu-clone', item, name);
|
||||
};
|
||||
|
||||
const onContextMenuDelete = (item: FileNavItem) => {
|
||||
emit('ctx-menu-delete', item);
|
||||
};
|
||||
|
||||
const onContentChange = (cm: Editor) => {
|
||||
const content = cm.getValue();
|
||||
if (!activeFileItem.value) return;
|
||||
emit('content-change', content);
|
||||
|
||||
// update in cache
|
||||
updateContentCache(activeFileItem.value, content);
|
||||
};
|
||||
|
||||
const onTabClick = (tab: FileNavItem) => {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, tab);
|
||||
emit('tab-click', tab);
|
||||
|
||||
// get from cache and update content
|
||||
getContentCache(tab);
|
||||
};
|
||||
|
||||
const closeTab = (tab: FileNavItem) => {
|
||||
const idx = tabs.value.findIndex(t => t.path === tab.path);
|
||||
if (idx !== -1) {
|
||||
tabs.value.splice(idx, 1);
|
||||
}
|
||||
if (activeFileItem.value) {
|
||||
if (activeFileItem.value.path === tab.path) {
|
||||
if (idx === 0) {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, tabs.value[0]);
|
||||
} else {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, tabs.value[idx - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// get from cache
|
||||
setTimeout(() => {
|
||||
if (activeFileItem.value) {
|
||||
getContentCache(activeFileItem.value);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// delete in cache
|
||||
deleteContentCache(tab);
|
||||
};
|
||||
|
||||
const onTabClose = (tab: FileNavItem) => {
|
||||
closeTab(tab);
|
||||
};
|
||||
|
||||
const onTabCloseOthers = (tab: FileNavItem) => {
|
||||
tabs.value = [tab];
|
||||
store.commit(`${ns}/setActiveFileNavItem`, tab);
|
||||
|
||||
// clear cache and update current tab content
|
||||
deleteOtherContentCache(tab);
|
||||
};
|
||||
|
||||
const onTabCloseAll = () => {
|
||||
tabs.value = [];
|
||||
store.commit(`${ns}/resetActiveFileNavItem`);
|
||||
|
||||
// clear cache
|
||||
clearContentCache();
|
||||
};
|
||||
|
||||
const onTabDragEnd = (newTabs: FileNavItem[]) => {
|
||||
tabs.value = newTabs;
|
||||
};
|
||||
|
||||
const onShowMoreShow = () => {
|
||||
showMoreContextMenuVisible.value = true;
|
||||
};
|
||||
|
||||
const onShowMoreHide = () => {
|
||||
showMoreContextMenuVisible.value = false;
|
||||
};
|
||||
|
||||
const onClickShowMoreContextMenuItem = (tab: FileNavItem) => {
|
||||
store.commit(`${ns}/setActiveFileNavItem`, tab);
|
||||
emit('tab-click', tab);
|
||||
};
|
||||
|
||||
const keyMapSave = () => {
|
||||
if (!activeFileItem.value) return;
|
||||
emit('save-file', activeFileItem.value);
|
||||
};
|
||||
|
||||
const keyMapClose = () => {
|
||||
if (!activeFileItem.value) return;
|
||||
closeTab(activeFileItem.value);
|
||||
};
|
||||
|
||||
const addSaveKeyMap = (cm: Editor) => {
|
||||
const map = {
|
||||
'Cmd-S': keyMapSave,
|
||||
'Ctrl-S': keyMapSave,
|
||||
// 'Cmd-W': keyMapClose,
|
||||
'Ctrl-W': keyMapClose,
|
||||
} as KeyMap;
|
||||
cm.addKeyMap(map);
|
||||
};
|
||||
|
||||
const onToggleNavMenu = () => {
|
||||
navMenuCollapsed.value = !navMenuCollapsed.value;
|
||||
};
|
||||
|
||||
const listenToKeyboardEvents = () => {
|
||||
editor?.on('blur', () => {
|
||||
updateSearchInput();
|
||||
});
|
||||
};
|
||||
|
||||
const unlistenToKeyboardEvents = () => {
|
||||
document.onkeydown = null;
|
||||
};
|
||||
|
||||
watch(options, async () => {
|
||||
await Promise.all([
|
||||
updateMode(),
|
||||
updateTheme(),
|
||||
]);
|
||||
updateEditorOptions();
|
||||
updateStyle();
|
||||
});
|
||||
|
||||
const onDropFiles = (files: InputFile[]) => {
|
||||
emit('drop-files', files);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// init codemirror editor
|
||||
const el = codeMirrorEditor.value as HTMLElement;
|
||||
editor = getCodemirrorEditor(el, options.value);
|
||||
|
||||
// add save key map
|
||||
addSaveKeyMap(editor);
|
||||
|
||||
// on editor change
|
||||
editor.on('change', onContentChange);
|
||||
|
||||
// update editor options
|
||||
updateEditorOptions();
|
||||
|
||||
// update editor content
|
||||
updateEditorContent();
|
||||
|
||||
// update editor theme
|
||||
await updateTheme();
|
||||
|
||||
// update styles
|
||||
updateStyle();
|
||||
|
||||
// listen to keyboard events key
|
||||
listenToKeyboardEvents();
|
||||
|
||||
// init codemirror template
|
||||
const elTemplate = codeMirrorTemplate.value as HTMLElement;
|
||||
codeMirrorTemplateEditor = getCodemirrorEditor(elTemplate, options.value);
|
||||
codeMirrorTemplateEditor.setValue(codeMirrorTemplateContent.value);
|
||||
codeMirrorTemplateEditor.setOption('mode', 'text/x-python');
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// turnoff listening to keyboard events
|
||||
unlistenToKeyboardEvents();
|
||||
});
|
||||
|
||||
return {
|
||||
fileEditor,
|
||||
codeMirrorEditor,
|
||||
tabs,
|
||||
activeFileItem,
|
||||
fileSearchString,
|
||||
navMenuCollapsed,
|
||||
styleRef,
|
||||
codeMirrorTemplate,
|
||||
showSettings,
|
||||
showCodeMirrorEditor,
|
||||
navTabs,
|
||||
showMoreContextMenuVisible,
|
||||
languageMime,
|
||||
options,
|
||||
style,
|
||||
files,
|
||||
extraStyle,
|
||||
variables,
|
||||
onNavItemClick,
|
||||
onNavItemDbClick,
|
||||
onNavItemDrop,
|
||||
onContextMenuNewFile,
|
||||
onContextMenuNewDirectory,
|
||||
onContextMenuRename,
|
||||
onContextMenuClone,
|
||||
onContextMenuDelete,
|
||||
onContentChange,
|
||||
onTabClick,
|
||||
onTabClose,
|
||||
onTabCloseOthers,
|
||||
onTabCloseAll,
|
||||
onTabDragEnd,
|
||||
onToggleNavMenu,
|
||||
onShowMoreShow,
|
||||
onShowMoreHide,
|
||||
onClickShowMoreContextMenuItem,
|
||||
updateTabs,
|
||||
updateEditorContent,
|
||||
updateContentCache,
|
||||
onDropFiles,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.file-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
.nav-menu {
|
||||
flex-basis: $fileEditorNavMenuWidth;
|
||||
min-width: $fileEditorNavMenuWidth;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all $fileEditorNavMenuCollapseTransitionDuration;
|
||||
|
||||
&.collapsed {
|
||||
min-width: 0;
|
||||
flex-basis: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-menu-top-bar {
|
||||
flex-basis: $fileEditorNavMenuTopBarHeight;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
padding: 0 10px 0 0;
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-editor-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: calc(100% - #{$fileEditorNavMenuWidth});
|
||||
flex-direction: column;
|
||||
|
||||
.code-mirror-editor {
|
||||
flex: 1;
|
||||
|
||||
&.hidden {
|
||||
position: fixed;
|
||||
top: -100vh;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-tabs-suffix {
|
||||
width: 30px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: $fileEditorNavTabsHeight;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
.background {
|
||||
background-color: $fileEditorMaskBg;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.expand-files {
|
||||
width: 29px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-mirror-template {
|
||||
position: fixed;
|
||||
top: -100vh;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.file-editor .nav-menu .nav-menu-top-bar >>> .search.el-input .el-input__inner {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror.dialog-opened {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog.CodeMirror-dialog-top {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog.CodeMirror-dialog-bottom {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-search-field {
|
||||
background-color: transparent;
|
||||
border: 1px solid;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
479
frontend/src/components/File/FileEditorNavMenu.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColorGutters,
|
||||
borderRight: `1px solid ${style.backgroundColor}`
|
||||
}"
|
||||
ref="fileEditorNavMenu"
|
||||
class="file-editor-nav-menu"
|
||||
>
|
||||
<el-tree
|
||||
ref="tree"
|
||||
:render-after-expand="defaultExpandAll"
|
||||
:data="items"
|
||||
:expand-on-click-node="false"
|
||||
:highlight-current="false"
|
||||
:allow-drop="allowDrop"
|
||||
empty-text="No files available"
|
||||
icon-class="fa fa-angle-right"
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColorGutters,
|
||||
color: style.color,
|
||||
}"
|
||||
node-key="path"
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
draggable
|
||||
@node-drag-enter="onNodeDragEnter"
|
||||
@node-drag-leave="onNodeDragLeave"
|
||||
@node-drag-end="onNodeDragEnd"
|
||||
@node-drop="onNodeDrop"
|
||||
@node-click="onNodeClick"
|
||||
@node-contextmenu="onNodeContextMenuShow"
|
||||
@node-expand="onNodeExpand"
|
||||
@node-collapse="onNodeCollapse"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<FileEditorNavMenuContextMenu
|
||||
:clicking="contextMenuClicking"
|
||||
:visible="isShowContextMenu(data)"
|
||||
@hide="onNodeContextMenuHide"
|
||||
@clone="onNodeContextMenuClone(data)"
|
||||
@delete="onNodeContextMenuDelete(data)"
|
||||
@rename="onNodeContextMenuRename(data)"
|
||||
@new-file="onNodeContextMenuNewFile(data)"
|
||||
@new-directory="onNodeContextMenuNewDirectory(data)"
|
||||
>
|
||||
<div
|
||||
v-bind="getBindDir(data)"
|
||||
:class="getItemClass(data)"
|
||||
class="nav-item-wrapper"
|
||||
>
|
||||
<div class="background"/>
|
||||
<div class="nav-item">
|
||||
<span class="icon">
|
||||
<atom-material-icon :is-dir="data.is_dir" :name="data.name"/>
|
||||
</span>
|
||||
<span class="title">
|
||||
{{ data.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FileEditorNavMenuContextMenu>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, onUnmounted, reactive, ref} from 'vue';
|
||||
import {ClickOutside} from 'element-plus/lib/directives';
|
||||
import Node from 'element-plus/lib/el-tree/src/model/node';
|
||||
import {DropType} from 'element-plus/lib/el-tree/src/tree.type';
|
||||
import AtomMaterialIcon from '@/components/icon/AtomMaterialIcon.vue';
|
||||
import {KEY_CONTROL, KEY_META} from '@/constants/keyboard';
|
||||
import FileEditorNavMenuContextMenu from '@/components/file/FileEditorNavMenuContextMenu.vue';
|
||||
import {ElMessageBox, ElTree} from 'element-plus';
|
||||
import {useDropzone} from 'vue3-dropzone';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorNavMenu',
|
||||
components: {
|
||||
FileEditorNavMenuContextMenu,
|
||||
AtomMaterialIcon
|
||||
},
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
props: {
|
||||
activeItem: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
defaultExpandAll: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
style: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'node-click',
|
||||
'node-db-click',
|
||||
'node-drop',
|
||||
'ctx-menu-new-file',
|
||||
'ctx-menu-new-directory',
|
||||
'ctx-menu-rename',
|
||||
'ctx-menu-clone',
|
||||
'ctx-menu-delete',
|
||||
'drop-files',
|
||||
],
|
||||
setup(props, ctx) {
|
||||
const {emit} = ctx;
|
||||
|
||||
const tree = ref<typeof ElTree>();
|
||||
|
||||
const fileEditorNavMenu = ref<HTMLDivElement>();
|
||||
|
||||
const clickStatus = reactive<FileEditorNavMenuClickStatus>({
|
||||
clicked: false,
|
||||
item: undefined,
|
||||
});
|
||||
|
||||
const selectedCache = reactive<FileEditorNavMenuCache<boolean>>({});
|
||||
|
||||
const dragCache = reactive<FileEditorNavMenuCache<boolean>>({});
|
||||
|
||||
const isCtrlKeyPressed = ref<boolean>(false);
|
||||
|
||||
const activeContextMenuItem = ref<FileNavItem>();
|
||||
|
||||
const contextMenuClicking = ref<boolean>(false);
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
const defaultExpandedKeys = computed<string[]>(() => {
|
||||
return ['~'].concat(expandedKeys.value);
|
||||
});
|
||||
|
||||
const addDefaultExpandedKey = (key: string) => {
|
||||
if (!expandedKeys.value.includes(key)) expandedKeys.value.push(key);
|
||||
};
|
||||
|
||||
const removeDefaultExpandedKey = (key: string) => {
|
||||
if (!expandedKeys.value.includes(key)) return;
|
||||
const idx = expandedKeys.value.indexOf(key);
|
||||
expandedKeys.value.splice(idx, 1);
|
||||
};
|
||||
|
||||
const resetDefaultExpandedKeys = () => {
|
||||
expandedKeys.value = [];
|
||||
};
|
||||
|
||||
const resetClickStatus = () => {
|
||||
clickStatus.clicked = false;
|
||||
clickStatus.item = undefined;
|
||||
activeContextMenuItem.value = undefined;
|
||||
};
|
||||
|
||||
const updateSelectedMap = (item: FileNavItem) => {
|
||||
const key = item.path;
|
||||
if (!key) {
|
||||
console.warn('No path specified for FileNavItem');
|
||||
return;
|
||||
}
|
||||
if (!selectedCache[key]) {
|
||||
selectedCache[key] = false;
|
||||
}
|
||||
selectedCache[key] = !selectedCache[key];
|
||||
|
||||
// if Ctrl key is not pressed, clear other selection
|
||||
if (!isCtrlKeyPressed.value) {
|
||||
Object.keys(selectedCache).filter(k => k !== key).forEach(k => {
|
||||
selectedCache[k] = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onNodeClick = (item: FileNavItem) => {
|
||||
if (clickStatus.clicked && clickStatus.item?.path === item.path) {
|
||||
emit('node-db-click', item);
|
||||
updateSelectedMap(item);
|
||||
resetClickStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
clickStatus.item = item;
|
||||
clickStatus.clicked = true;
|
||||
setTimeout(() => {
|
||||
if (clickStatus.clicked) {
|
||||
emit('node-click', item);
|
||||
updateSelectedMap(item);
|
||||
}
|
||||
resetClickStatus();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onNodeContextMenuShow = (ev: Event, item: FileNavItem) => {
|
||||
contextMenuClicking.value = true;
|
||||
activeContextMenuItem.value = item;
|
||||
setTimeout(() => {
|
||||
contextMenuClicking.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const onNodeContextMenuHide = () => {
|
||||
activeContextMenuItem.value = undefined;
|
||||
};
|
||||
|
||||
const onNodeContextMenuNewFile = async (item: FileNavItem) => {
|
||||
const res = await ElMessageBox.prompt('Please enter the name of the new file', 'New File');
|
||||
emit('ctx-menu-new-file', item, res.value);
|
||||
};
|
||||
|
||||
const onNodeContextMenuNewDirectory = async (item: FileNavItem) => {
|
||||
const res = await ElMessageBox.prompt('Please enter the name of the new directory', 'New Directory');
|
||||
emit('ctx-menu-new-directory', item, res.value);
|
||||
};
|
||||
|
||||
const onNodeContextMenuRename = async (item: FileNavItem) => {
|
||||
const res = await ElMessageBox.prompt('Please enter the new name', 'Rename');
|
||||
emit('ctx-menu-rename', item, res.value);
|
||||
};
|
||||
|
||||
const onNodeContextMenuClone = async (item: FileNavItem) => {
|
||||
const res = await ElMessageBox.prompt('Please enter the new name', 'Clone');
|
||||
emit('ctx-menu-clone', item, res.value);
|
||||
};
|
||||
|
||||
const onNodeContextMenuDelete = async (item: FileNavItem) => {
|
||||
await ElMessageBox.confirm('Are you sure to delete?', 'Delete');
|
||||
emit('ctx-menu-delete', item);
|
||||
};
|
||||
|
||||
const onNodeDragEnter = (draggingNode: Node, dropNode: Node) => {
|
||||
const item = dropNode.data as FileNavItem;
|
||||
if (!item.path) return;
|
||||
dragCache[item.path] = true;
|
||||
};
|
||||
|
||||
const onNodeDragLeave = (draggingNode: Node, dropNode: Node) => {
|
||||
const item = dropNode.data as FileNavItem;
|
||||
if (!item.path) return;
|
||||
dragCache[item.path] = false;
|
||||
};
|
||||
|
||||
const onNodeDragEnd = () => {
|
||||
for (const key in dragCache) {
|
||||
dragCache[key] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onNodeDrop = (draggingNode: Node, dropNode: Node) => {
|
||||
const draggingItem = draggingNode.data as FileNavItem;
|
||||
const dropItem = dropNode.data as FileNavItem;
|
||||
emit('node-drop', draggingItem, dropItem);
|
||||
};
|
||||
|
||||
const onNodeExpand = (data: FileNavItem) => {
|
||||
addDefaultExpandedKey(data.path as string);
|
||||
};
|
||||
|
||||
const onNodeCollapse = (data: FileNavItem) => {
|
||||
removeDefaultExpandedKey(data.path as string);
|
||||
};
|
||||
|
||||
const isSelected = (item: FileNavItem): boolean => {
|
||||
if (!item.path) return false;
|
||||
return selectedCache[item.path] || false;
|
||||
};
|
||||
|
||||
const isDroppable = (item: FileNavItem): boolean => {
|
||||
if (!item.path) return false;
|
||||
return dragCache[item.path] || false;
|
||||
};
|
||||
|
||||
const isShowContextMenu = (item: FileNavItem) => {
|
||||
return activeContextMenuItem.value?.path === item.path;
|
||||
};
|
||||
|
||||
const allowDrop = (draggingNode: Node, dropNode: Node, type: DropType) => {
|
||||
if (type !== 'inner') return false;
|
||||
if (draggingNode.data?.path === dropNode.data?.path) return false;
|
||||
if (draggingNode.parent?.data?.path === dropNode.data?.path) return false;
|
||||
const item = dropNode.data as FileNavItem;
|
||||
return item.is_dir;
|
||||
};
|
||||
|
||||
const getItemClass = (item: FileNavItem): string[] => {
|
||||
const cls = [];
|
||||
if (isSelected(item)) cls.push('selected');
|
||||
if (isDroppable(item)) cls.push('droppable');
|
||||
return cls;
|
||||
};
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
} = useDropzone({
|
||||
onDrop: (files: InputFile[]) => {
|
||||
emit('drop-files', files);
|
||||
},
|
||||
});
|
||||
|
||||
const getBindDir = (item: FileNavItem) => getRootProps({
|
||||
onDragEnter: (ev: DragEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!item.is_dir || !item.path) return;
|
||||
dragCache[item.path] = true;
|
||||
},
|
||||
onDragLeave: (ev: DragEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!item.is_dir || !item.path) return;
|
||||
dragCache[item.path] = false;
|
||||
},
|
||||
onDrop: () => {
|
||||
for (const key in dragCache) {
|
||||
dragCache[key] = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// listen to keyboard events
|
||||
document.onkeydown = (ev: KeyboardEvent) => {
|
||||
if (!ev) return;
|
||||
if (ev.key === KEY_CONTROL || ev.key === KEY_META) {
|
||||
isCtrlKeyPressed.value = true;
|
||||
}
|
||||
};
|
||||
document.onkeyup = (ev: KeyboardEvent) => {
|
||||
if (!ev) return;
|
||||
if (ev.key === KEY_CONTROL || ev.key === KEY_META) {
|
||||
isCtrlKeyPressed.value = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// turnoff listening to keyboard events
|
||||
document.onkeydown = null;
|
||||
document.onkeyup = null;
|
||||
});
|
||||
|
||||
return {
|
||||
tree,
|
||||
activeContextMenuItem,
|
||||
fileEditorNavMenu,
|
||||
contextMenuClicking,
|
||||
defaultExpandedKeys,
|
||||
onNodeClick,
|
||||
onNodeContextMenuShow,
|
||||
onNodeContextMenuHide,
|
||||
onNodeContextMenuNewFile,
|
||||
onNodeContextMenuNewDirectory,
|
||||
onNodeContextMenuRename,
|
||||
onNodeContextMenuClone,
|
||||
onNodeContextMenuDelete,
|
||||
onNodeDragEnter,
|
||||
onNodeDragLeave,
|
||||
onNodeDragEnd,
|
||||
onNodeDrop,
|
||||
onNodeExpand,
|
||||
onNodeCollapse,
|
||||
isSelected,
|
||||
isDroppable,
|
||||
isShowContextMenu,
|
||||
allowDrop,
|
||||
getItemClass,
|
||||
resetDefaultExpandedKeys,
|
||||
addDefaultExpandedKey,
|
||||
removeDefaultExpandedKey,
|
||||
getBindDir,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.file-editor-nav-menu {
|
||||
flex: 1;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
.el-tree {
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
max-width: fit-content;
|
||||
|
||||
.el-tree-node {
|
||||
.nav-item-wrapper {
|
||||
z-index: 2;
|
||||
|
||||
& * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
.background {
|
||||
background-color: $fileEditorMaskBg;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: $fileEditorNavMenuItemSelectedColor;
|
||||
}
|
||||
}
|
||||
|
||||
&.droppable {
|
||||
& * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border: 1px dashed $fileEditorNavMenuItemDragTargetBorderColor;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.background:hover + .nav-item {
|
||||
color: $fileEditorNavMenuItemSelectedColor;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
.file-editor-nav-menu >>> .el-tree .el-tree-node > .el-tree-node__content {
|
||||
background-color: inherit;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.file-editor-nav-menu >>> .el-tree .el-tree-node > .el-tree-node__content .el-tree-node__expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-editor-nav-menu >>> .el-tree .el-tree-node * {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<ContextMenu :clicking="clicking" :placement="placement" :visible="visible" @hide="$emit('hide')">
|
||||
<template #default>
|
||||
<ContextMenuList :items="items" @hide="$emit('hide')"/>
|
||||
</template>
|
||||
<template #reference>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, readonly} from 'vue';
|
||||
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
|
||||
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorNavMenuContextMenu',
|
||||
components: {ContextMenuList, ContextMenu},
|
||||
props: contextMenuDefaultProps,
|
||||
emits: [
|
||||
'hide',
|
||||
'new-file',
|
||||
'new-directory',
|
||||
'rename',
|
||||
'clone',
|
||||
'delete',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const items = readonly<ContextMenuItem[]>([
|
||||
{title: 'New File', icon: ['fa', 'file-alt'], action: () => emit('new-file')},
|
||||
{title: 'New Directory', icon: ['fa', 'folder-plus'], action: () => emit('new-directory')},
|
||||
{title: 'Rename', icon: ['fa', 'edit'], action: () => emit('rename')},
|
||||
{title: 'Duplicate', icon: ['fa', 'clone'], action: () => emit('clone')},
|
||||
{title: 'Delete', icon: ['fa', 'trash'], action: () => emit('delete')},
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
271
frontend/src/components/File/FileEditorNavTabs.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
ref="navTabs"
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColorGutters,
|
||||
color: style.color,
|
||||
}"
|
||||
class="file-editor-nav-tabs"
|
||||
>
|
||||
<slot name="prefix"></slot>
|
||||
<DraggableList
|
||||
item-key="path"
|
||||
:items="tabs"
|
||||
@d-end="onDragEnd"
|
||||
>
|
||||
<template v-slot="{item}">
|
||||
<FileEditorNavTabsContextMenu
|
||||
:clicking="contextMenuClicking"
|
||||
:visible="isShowContextMenu(item)"
|
||||
@close="onClose(item)"
|
||||
@hide="onContextMenuHide"
|
||||
@close-others="onCloseOthers(item)"
|
||||
@close-all="onCloseAll"
|
||||
>
|
||||
<div
|
||||
:class="activeTab && activeTab.path === item.path ? 'active' : ''"
|
||||
:style="{
|
||||
backgroundColor: style.backgroundColor,
|
||||
}"
|
||||
class="file-editor-nav-tab"
|
||||
@click="onClick(item)"
|
||||
@contextmenu.prevent="onContextMenuShow(item)"
|
||||
>
|
||||
<span class="icon">
|
||||
<atom-material-icon :is-dir="item.is_dir" :name="item.name"/>
|
||||
</span>
|
||||
<el-tooltip :content="getTitle(item)" :show-after="500">
|
||||
<span class="title">
|
||||
{{ getTitle(item) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span class="close-btn" @click.stop="onClose(item)">
|
||||
<i class="el-icon-close"></i>
|
||||
</span>
|
||||
<div class="background"/>
|
||||
</div>
|
||||
</FileEditorNavTabsContextMenu>
|
||||
</template>
|
||||
</DraggableList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, ref, watch} from 'vue';
|
||||
import DraggableList from '@/components/drag/DraggableList.vue';
|
||||
import AtomMaterialIcon from '@/components/icon/AtomMaterialIcon.vue';
|
||||
import FileEditorNavTabsContextMenu from '@/components/file/FileEditorNavTabsContextMenu.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorNavTabs',
|
||||
components: {FileEditorNavTabsContextMenu, AtomMaterialIcon, DraggableList},
|
||||
props: {
|
||||
activeTab: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
style: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'tab-click',
|
||||
'tab-close',
|
||||
'tab-close-others',
|
||||
'tab-close-all',
|
||||
'tab-dragend',
|
||||
'show-more',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const activeContextMenuItem = ref<FileNavItem>();
|
||||
|
||||
const navTabs = ref<HTMLDivElement>();
|
||||
|
||||
const navTabsWidth = ref<number>();
|
||||
|
||||
const navTabsOverflowWidth = ref<number>();
|
||||
|
||||
const showMoreVisible = computed<boolean>(() => {
|
||||
if (navTabsWidth.value === undefined || navTabsOverflowWidth.value === undefined) return false;
|
||||
return navTabsOverflowWidth.value > navTabsWidth.value;
|
||||
});
|
||||
|
||||
const contextMenuClicking = ref<boolean>(false);
|
||||
|
||||
const tabs = computed<FileNavItem[]>(() => {
|
||||
const {tabs} = props as FileEditorNavTabsProps;
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const getTitle = (item: FileNavItem) => {
|
||||
return item.name;
|
||||
};
|
||||
|
||||
const onClick = (item: FileNavItem) => {
|
||||
emit('tab-click', item);
|
||||
};
|
||||
|
||||
const onClose = (item: FileNavItem) => {
|
||||
emit('tab-close', item);
|
||||
};
|
||||
|
||||
const onCloseOthers = (item: FileNavItem) => {
|
||||
emit('tab-close-others', item);
|
||||
};
|
||||
|
||||
const onCloseAll = () => {
|
||||
emit('tab-close-all');
|
||||
};
|
||||
|
||||
const onDragEnd = (items: FileNavItem[]) => {
|
||||
emit('tab-dragend', items);
|
||||
};
|
||||
|
||||
const onContextMenuShow = (item: FileNavItem) => {
|
||||
contextMenuClicking.value = true;
|
||||
activeContextMenuItem.value = item;
|
||||
|
||||
setTimeout(() => {
|
||||
contextMenuClicking.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const onContextMenuHide = () => {
|
||||
activeContextMenuItem.value = undefined;
|
||||
};
|
||||
|
||||
const isShowContextMenu = (item: FileNavItem) => {
|
||||
return activeContextMenuItem.value?.path === item.path;
|
||||
};
|
||||
|
||||
const updateWidths = () => {
|
||||
if (!navTabs.value) return;
|
||||
|
||||
// width
|
||||
navTabsWidth.value = Number(getComputedStyle(navTabs.value).width.replace('px', ''));
|
||||
|
||||
// overflow width
|
||||
const el = navTabs.value.querySelector('.draggable-list');
|
||||
if (el) {
|
||||
navTabsOverflowWidth.value = Number(getComputedStyle(el).width.replace('px', ''));
|
||||
}
|
||||
};
|
||||
|
||||
watch(tabs.value, () => {
|
||||
setTimeout(updateWidths, 100);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// update tabs widths
|
||||
updateWidths();
|
||||
});
|
||||
|
||||
return {
|
||||
activeContextMenuItem,
|
||||
navTabs,
|
||||
navTabsWidth,
|
||||
navTabsOverflowWidth,
|
||||
showMoreVisible,
|
||||
contextMenuClicking,
|
||||
getTitle,
|
||||
onClick,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
onDragEnd,
|
||||
onContextMenuShow,
|
||||
onContextMenuHide,
|
||||
isShowContextMenu,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.file-editor-nav-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
height: $fileEditorNavTabsHeight;
|
||||
|
||||
.file-editor-nav-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
height: $fileEditorNavTabsHeight;
|
||||
max-width: $fileEditorNavTabsItemMaxWidth;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
.background {
|
||||
background-color: $fileEditorMaskBg;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom: 2px solid $primaryColor;
|
||||
|
||||
.title {
|
||||
color: $fileEditorNavTabsItemActiveColor;
|
||||
}
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: $fileEditorNavTabsItemColor;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
margin-left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.suffix {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ContextMenu :clicking="clicking" :placement="placement" :visible="visible" @hide="$emit('hide')">
|
||||
<template #default>
|
||||
<ContextMenuList :items="items" @hide="$emit('hide')"/>
|
||||
</template>
|
||||
<template #reference>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, readonly} from 'vue';
|
||||
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
|
||||
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorNavTabsContextMenu',
|
||||
components: {ContextMenuList, ContextMenu},
|
||||
props: contextMenuDefaultProps,
|
||||
emits: [
|
||||
'hide',
|
||||
'close',
|
||||
'close-others',
|
||||
'close-all',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const items = readonly<ContextMenuItem[]>([
|
||||
{title: 'Close', icon: ['fa', 'times'], action: () => emit('close')},
|
||||
{title: 'Close Others', action: () => emit('close-others')},
|
||||
{title: 'Close All', action: () => emit('close-all')},
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<ContextMenu :placement="placement" :visible="visible" @hide="$emit('hide')">
|
||||
<template #default>
|
||||
<ContextMenuList :items="items" @hide="$emit('hide')"/>
|
||||
</template>
|
||||
<template #reference>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
|
||||
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorNavTabsShowMoreContextMenu',
|
||||
components: {ContextMenuList, ContextMenu},
|
||||
props: {
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
...contextMenuDefaultProps,
|
||||
},
|
||||
emits: [
|
||||
'tab-click',
|
||||
],
|
||||
setup(props, {emit}) {
|
||||
const items = computed<ContextMenuItem[]>(() => {
|
||||
const {tabs} = props as FileEditorNavTabsShowMoreContextMenuProps;
|
||||
const contextMenuItems: ContextMenuItem[] = tabs.map(t => {
|
||||
return {
|
||||
title: t.path || '',
|
||||
icon: t.name || '',
|
||||
action: () => emit('tab-click', t),
|
||||
};
|
||||
});
|
||||
return contextMenuItems;
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
198
frontend/src/components/File/FileEditorSettingsDialog.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="file-editor-settings-dialog">
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="File Editor Settings"
|
||||
@close="onClose"
|
||||
>
|
||||
<el-menu :default-active="activeTabName" class="nav-menu" mode="horizontal" @select="onTabChange">
|
||||
<el-menu-item v-for="tab in tabs" :key="tab.name" :index="tab.name">
|
||||
{{ tab.title }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<el-form
|
||||
:label-width="variables.fileEditorSettingsDialogLabelWidth"
|
||||
class="form"
|
||||
size="small"
|
||||
>
|
||||
<el-form-item
|
||||
v-for="name in optionNames[activeTabName]"
|
||||
:key="name"
|
||||
>
|
||||
<template #label>
|
||||
<el-tooltip :content="getDefinitionDescription(name)" popper-class="help-tooltip" trigger="click">
|
||||
<font-awesome-icon :icon="['far', 'question-circle']" class="icon" size="sm"/>
|
||||
</el-tooltip>
|
||||
{{ getDefinitionTitle(name) }}
|
||||
</template>
|
||||
<FileEditorSettingsFormItem v-model="options[name]" :name="name"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button plain size="small" type="info" @click="onClose">Cancel</el-button>
|
||||
<el-button size="small" type="primary" @click="onConfirm">Save</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onBeforeMount, readonly, ref} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import {plainClone} from '@/utils/object';
|
||||
import variables from '@/styles/variables.scss';
|
||||
import {getOptionDefinition, getThemes} from '@/utils/codemirror';
|
||||
import FileEditorSettingsFormItem from '@/components/file/FileEditorSettingsFormItem.vue';
|
||||
import {onBeforeRouteLeave} from 'vue-router';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorSettingsDialog',
|
||||
components: {FileEditorSettingsFormItem},
|
||||
setup() {
|
||||
const storeNamespace = 'file';
|
||||
const store = useStore();
|
||||
const {file} = store.state as RootStoreState;
|
||||
|
||||
const options = ref<FileEditorConfiguration>({});
|
||||
|
||||
const tabs = readonly([
|
||||
{name: 'general', title: 'General'},
|
||||
{name: 'edit', title: 'Edit'},
|
||||
{name: 'indentation', title: 'Indentation'},
|
||||
{name: 'cursor', title: 'Cursor'},
|
||||
]);
|
||||
|
||||
const optionNames = readonly({
|
||||
general: [
|
||||
'theme',
|
||||
'keyMap',
|
||||
'lineWrapping',
|
||||
'lineNumbers',
|
||||
'maxHighlightLength',
|
||||
'spellcheck',
|
||||
'autocorrect',
|
||||
'autocapitalize',
|
||||
],
|
||||
edit: [
|
||||
'lineWiseCopyCut',
|
||||
'pasteLinesPerSelection',
|
||||
'undoDepth',
|
||||
],
|
||||
indentation: [
|
||||
'indentUnit',
|
||||
'smartIndent',
|
||||
'tabSize',
|
||||
'indentWithTabs',
|
||||
'electricChars',
|
||||
],
|
||||
cursor: [
|
||||
'showCursorWhenSelecting',
|
||||
'cursorBlinkRate',
|
||||
'cursorScrollMargin',
|
||||
'cursorHeight',
|
||||
],
|
||||
});
|
||||
|
||||
const activeTabName = ref<string>(tabs[0].name);
|
||||
|
||||
const visible = computed<boolean>(() => {
|
||||
const {editorSettingsDialogVisible} = file;
|
||||
return editorSettingsDialogVisible;
|
||||
});
|
||||
|
||||
const themes = computed<string[]>(() => {
|
||||
return getThemes();
|
||||
});
|
||||
|
||||
const resetOptions = () => {
|
||||
const {editorOptions} = file;
|
||||
options.value = plainClone(editorOptions);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
|
||||
resetOptions();
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
store.commit(`${storeNamespace}/setEditorOptions`, options.value);
|
||||
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
|
||||
resetOptions();
|
||||
};
|
||||
|
||||
const onTabChange = (tabName: string) => {
|
||||
activeTabName.value = tabName;
|
||||
};
|
||||
|
||||
const getDefinitionDescription = (name: string) => {
|
||||
return getOptionDefinition(name)?.description;
|
||||
};
|
||||
|
||||
const getDefinitionTitle = (name: string) => {
|
||||
return getOptionDefinition(name)?.title;
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
resetOptions();
|
||||
});
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
|
||||
});
|
||||
|
||||
return {
|
||||
variables,
|
||||
options,
|
||||
activeTabName,
|
||||
tabs,
|
||||
optionNames,
|
||||
visible,
|
||||
themes,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onTabChange,
|
||||
getDefinitionDescription,
|
||||
getDefinitionTitle,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-editor-settings-dialog {
|
||||
.nav-menu {
|
||||
.el-menu-item {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.file-editor-settings-dialog >>> .el-dialog .el-dialog__body {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__label .icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content .el-input,
|
||||
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content .el-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.help-tooltip {
|
||||
max-width: 240px;
|
||||
}
|
||||
</style>
|
||||
50
frontend/src/components/File/FileEditorSettingsFormItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<el-select v-if="type === 'select'" :value="value" @change="onChange">
|
||||
<el-option v-for="op in data.options" :key="op" :label="op" :value="op"/>
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else-if="type === 'input-number'"
|
||||
:min="data.min !== undefined ? data.min : 0"
|
||||
:step="data.step !== undefined ? data.step : 1"
|
||||
:value="value"
|
||||
@change="onChange"
|
||||
/>
|
||||
<el-switch v-else-if="type === 'switch'" :value="value" @change="onChange"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
import {getOptionDefinition} from '@/utils/codemirror';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEditorSettingsFormItem',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, {emit}) {
|
||||
const def = computed<FileEditorOptionDefinition | undefined>(() => {
|
||||
const {name} = props;
|
||||
return getOptionDefinition(name);
|
||||
});
|
||||
const type = computed<FileEditorOptionDefinitionType | undefined>(() => def.value?.type);
|
||||
const data = computed<any>(() => def.value?.data);
|
||||
|
||||
const onChange = (value: any) => {
|
||||
emit('input', value);
|
||||
};
|
||||
|
||||
return {
|
||||
type,
|
||||
data,
|
||||
onChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,679 +0,0 @@
|
||||
<template>
|
||||
<div class="file-list-container">
|
||||
<el-dialog
|
||||
:title="$t('New Directory')"
|
||||
:visible.sync="dirDialogVisible"
|
||||
width="30%"
|
||||
>
|
||||
<el-form>
|
||||
<el-form-item :label="$t('Enter new directory name')">
|
||||
<el-input v-model="name" :placeholder="$t('New directory name')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="dirDialogVisible = false">{{ $t('Cancel') }}</el-button>
|
||||
<el-button type="primary" @click="onAddDir">{{ $t('Confirm') }}</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
:title="$t('New File')"
|
||||
:visible.sync="fileDialogVisible"
|
||||
width="30%"
|
||||
>
|
||||
<el-form>
|
||||
<el-form-item :label="$t('Enter new file name')">
|
||||
<el-input v-model="name" :placeholder="$t('New file name')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button size="small" @click="fileDialogVisible = false">{{ $t('Cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" @click="onAddFile">{{ $t('Confirm') }}</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<div
|
||||
class="file-tree-wrapper"
|
||||
>
|
||||
<el-tree
|
||||
ref="tree"
|
||||
class="tree"
|
||||
:data="computedFileTree"
|
||||
node-key="path"
|
||||
:highlight-current="true"
|
||||
:default-expanded-keys="expandedPaths"
|
||||
@node-contextmenu="onFileRightClick"
|
||||
@node-click="onFileClick"
|
||||
@node-expand="onDirClick"
|
||||
@node-collapse="onDirClick"
|
||||
>
|
||||
<span slot-scope="{ node, data }" class="custom-tree-node">
|
||||
<el-popover
|
||||
v-model="isShowCreatePopoverDict[data.path]"
|
||||
trigger="manual"
|
||||
placement="right"
|
||||
popper-class="create-item-popover"
|
||||
:visible-arrow="false"
|
||||
@hide="onHideCreate(data)"
|
||||
>
|
||||
<ul class="action-item-list">
|
||||
<li class="action-item" @click="fileDialogVisible = true">
|
||||
<font-awesome-icon icon="file-alt" color="rgba(3,47,98,.5)" />
|
||||
<span class="action-item-text">{{ $t('Create File') }}</span>
|
||||
</li>
|
||||
<li class="action-item" @click="dirDialogVisible = true">
|
||||
<font-awesome-icon :icon="['fa', 'folder']" color="rgba(3,47,98,.5)" />
|
||||
<span class="action-item-text">{{ $t('Create Directory') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="action-item-list">
|
||||
<li class="action-item" @click="onClickRemoveNav(data)">
|
||||
<font-awesome-icon :icon="['fa', 'trash']" color="rgba(3,47,98,.5)" />
|
||||
<span class="action-item-text">{{ $t('Remove') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<template slot="reference">
|
||||
<div>
|
||||
<span class="item-icon">
|
||||
<font-awesome-icon v-if="data.is_dir" :icon="['fa', 'folder']" color="rgba(3,47,98,.5)" />
|
||||
<font-awesome-icon
|
||||
v-else-if="data.path.match(/\.py$/)"
|
||||
:icon="['fab','python']"
|
||||
color="rgba(3,47,98,.5)"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else-if="data.path.match(/\.js$/)"
|
||||
:icon="['fab','node-js']"
|
||||
color="rgba(3,47,98,.5)"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else-if="data.path.match(/\.(java|jar|class)$/)"
|
||||
:icon="['fab','java']"
|
||||
color="rgba(3,47,98,.5)"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else-if="data.path.match(/\.go$/)"
|
||||
:icon="['fab','go']"
|
||||
color="rgba(3,47,98,.5)"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else-if="data.path.match(/\.zip$/)"
|
||||
:icon="['fa','file-archive']"
|
||||
color="rgba(3,47,98,.5)"
|
||||
/>
|
||||
<font-awesome-icon v-else icon="file-alt" color="rgba(3,47,98,.5)" />
|
||||
</span>
|
||||
<span class="item-name" :class="isActiveFile(data) ? 'active' : ''">
|
||||
{{ data.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</span>
|
||||
</el-tree>
|
||||
<div
|
||||
class="add-btn-wrapper"
|
||||
>
|
||||
<el-popover
|
||||
trigger="click"
|
||||
placement="right"
|
||||
popper-class="create-item-popover"
|
||||
:visible-arrow="false"
|
||||
>
|
||||
<ul class="action-item-list">
|
||||
<li class="action-item" @click="fileDialogVisible = true">
|
||||
<font-awesome-icon icon="file-alt" color="rgba(3,47,98,.5)" />
|
||||
<span class="action-item-text">{{ $t('Create File') }}</span>
|
||||
</li>
|
||||
<li class="action-item" @click="dirDialogVisible = true">
|
||||
<font-awesome-icon :icon="['fa', 'folder']" color="rgba(3,47,98,.5)" />
|
||||
<span class="action-item-text">{{ $t('Create Directory') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<el-button
|
||||
slot="reference"
|
||||
class="add-btn"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
:disabled="isDisabled"
|
||||
@click="onEmptyClick"
|
||||
>
|
||||
{{ $t('Add') }}
|
||||
</el-button>
|
||||
</el-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div v-if="!showFile" class="file-list">
|
||||
{{ $t('Please select a file or click the add button on the left.') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="top-part">
|
||||
<!--back-->
|
||||
<div class="action-container">
|
||||
<el-popover v-model="isShowDelete" trigger="click">
|
||||
<el-button size="small" type="default" @click="() => this.isShowDelete = false">
|
||||
{{ $t('Cancel') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="onFileDelete">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
<template slot="reference">
|
||||
<el-button type="danger" size="small" style="margin-right: 10px;" :disabled="isDisabled">
|
||||
<font-awesome-icon :icon="['fa', 'trash']" />
|
||||
{{ $t('Remove') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-popover v-model="isShowRename" trigger="click">
|
||||
<el-input v-model="name" :placeholder="$t('Name')" style="margin-bottom: 10px" />
|
||||
<div style="text-align: right">
|
||||
<el-button size="small" type="warning" @click="onRenameFile">
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<template slot="reference">
|
||||
<div>
|
||||
<el-button type="warning" size="small" style="margin-right: 10px;" :disabled="isDisabled" @click="onOpenRename">
|
||||
<font-awesome-icon :icon="['fa', 'redo']" />
|
||||
{{ $t('Rename') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button type="success" size="small" style="margin-right: 10px;" :disabled="isDisabled" @click="onFileSave">
|
||||
<font-awesome-icon :icon="['fa', 'save']" />
|
||||
{{ $t('Save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!--./back-->
|
||||
|
||||
<!--file path-->
|
||||
<div class="file-path-container">
|
||||
<div class="file-path">{{ currentPath }}</div>
|
||||
</div>
|
||||
<!--./file path-->
|
||||
</div>
|
||||
<file-detail />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState,
|
||||
mapGetters
|
||||
} from 'vuex'
|
||||
import FileDetail from './FileDetail'
|
||||
|
||||
export default {
|
||||
name: 'FileList',
|
||||
components: { FileDetail },
|
||||
data() {
|
||||
return {
|
||||
isEdit: false,
|
||||
showFile: false,
|
||||
name: '',
|
||||
isShowAdd: false,
|
||||
isShowDelete: false,
|
||||
isShowRename: false,
|
||||
isShowCreatePopoverDict: {},
|
||||
currentFilePath: '.',
|
||||
ignoreFileRegexList: [
|
||||
'__pycache__',
|
||||
'md5.txt',
|
||||
'.pyc',
|
||||
'.git'
|
||||
],
|
||||
activeFileNode: {},
|
||||
dirDialogVisible: false,
|
||||
fileDialogVisible: false,
|
||||
nodeExpandedDict: {},
|
||||
isShowDeleteNav: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'fileTree',
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('file', [
|
||||
'fileList'
|
||||
]),
|
||||
...mapGetters('user', [
|
||||
'userInfo'
|
||||
]),
|
||||
currentPath: {
|
||||
set(value) {
|
||||
this.$store.commit('file/SET_CURRENT_PATH', value)
|
||||
},
|
||||
get() {
|
||||
return this.$store.state.file.currentPath
|
||||
}
|
||||
},
|
||||
computedFileTree() {
|
||||
if (!this.fileTree || !this.fileTree.children) return []
|
||||
let nodes = this.sortFiles(this.fileTree.children)
|
||||
nodes = this.filterFiles(nodes)
|
||||
return nodes
|
||||
},
|
||||
expandedPaths() {
|
||||
return Object.keys(this.nodeExpandedDict)
|
||||
.map(path => {
|
||||
return {
|
||||
path,
|
||||
expanded: this.nodeExpandedDict[path]
|
||||
}
|
||||
})
|
||||
.filter(d => d.expanded)
|
||||
.map(d => d.path)
|
||||
},
|
||||
isDisabled() {
|
||||
return this.spiderForm.is_public && this.spiderForm.username !== this.userInfo.username && this.userInfo.role !== 'admin'
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.getFileTree()
|
||||
},
|
||||
mounted() {
|
||||
this.listener = document.querySelector('body').addEventListener('click', ev => {
|
||||
this.isShowCreatePopoverDict = {}
|
||||
})
|
||||
},
|
||||
destroyed() {
|
||||
document.querySelector('body').removeEventListener('click', this.listener)
|
||||
},
|
||||
methods: {
|
||||
onEdit() {
|
||||
this.isEdit = true
|
||||
},
|
||||
onItemClick(item) {
|
||||
if (item.is_dir) {
|
||||
// 目录
|
||||
this.$store.dispatch('file/getFileList', { path: item.path })
|
||||
} else {
|
||||
// 文件
|
||||
this.showFile = true
|
||||
this.$store.commit('file/SET_FILE_CONTENT', '')
|
||||
this.$store.commit('file/SET_CURRENT_PATH', item.path)
|
||||
this.$store.dispatch('file/getFileContent', { path: item.path })
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', '文件', '点击')
|
||||
},
|
||||
async onFileSave() {
|
||||
await this.$store.dispatch('file/saveFileContent', { path: this.currentPath })
|
||||
this.$message.success(this.$t('Saved file successfully'))
|
||||
this.$st.sendEv('爬虫详情', '文件', '保存')
|
||||
},
|
||||
async onAddFile() {
|
||||
if (!this.name) {
|
||||
this.$message.error(this.$t('Name cannot be empty'))
|
||||
return
|
||||
}
|
||||
const arr = this.activeFileNode.path.split('/')
|
||||
if (this.activeFileNode.is_dir) {
|
||||
arr.push(this.name)
|
||||
} else {
|
||||
arr[arr.length - 1] = this.name
|
||||
}
|
||||
const path = arr.join('/')
|
||||
await this.$store.dispatch('file/addFile', { path })
|
||||
await this.$store.dispatch('spider/getFileTree')
|
||||
this.isShowAdd = false
|
||||
this.fileDialogVisible = false
|
||||
this.showFile = true
|
||||
this.$store.commit('file/SET_FILE_CONTENT', '')
|
||||
this.$store.commit('file/SET_CURRENT_PATH', path)
|
||||
await this.$store.dispatch('file/getFileContent', { path })
|
||||
this.$st.sendEv('爬虫详情', '文件', '添加')
|
||||
},
|
||||
async onAddDir() {
|
||||
if (!this.name) {
|
||||
this.$message.error(this.$t('Name cannot be empty'))
|
||||
return
|
||||
}
|
||||
const arr = this.activeFileNode.path.split('/')
|
||||
if (this.activeFileNode.is_dir) {
|
||||
arr.push(this.name)
|
||||
} else {
|
||||
arr[arr.length - 1] = this.name
|
||||
}
|
||||
const path = arr.join('/')
|
||||
await this.$store.dispatch('file/addDir', { path })
|
||||
await this.$store.dispatch('spider/getFileTree')
|
||||
this.isShowAdd = false
|
||||
this.dirDialogVisible = false
|
||||
this.$st.sendEv('爬虫详情', '文件', '添加')
|
||||
},
|
||||
async onFileDelete() {
|
||||
await this.$store.dispatch('file/deleteFile', { path: this.currentFilePath })
|
||||
await this.$store.dispatch('spider/getFileTree')
|
||||
this.$message.success(this.$t('Deleted successfully'))
|
||||
this.isShowDelete = false
|
||||
this.showFile = false
|
||||
this.$st.sendEv('爬虫详情', '文件', '删除')
|
||||
},
|
||||
onOpenRename() {
|
||||
const arr = this.currentFilePath.split('/')
|
||||
this.name = arr[arr.length - 1]
|
||||
},
|
||||
async onRenameFile() {
|
||||
await this.$store.dispatch('file/renameFile', { path: this.currentFilePath, newPath: this.name })
|
||||
await this.$store.dispatch('spider/getFileTree')
|
||||
const arr = this.currentFilePath.split('/')
|
||||
arr[arr.length - 1] = this.name
|
||||
this.currentFilePath = arr.join('/')
|
||||
this.$store.commit('file/SET_CURRENT_PATH', this.currentFilePath)
|
||||
this.$message.success(this.$t('Renamed successfully'))
|
||||
this.isShowRename = false
|
||||
this.$st.sendEv('爬虫详情', '文件', '重命名')
|
||||
},
|
||||
async getFileTree() {
|
||||
const arr = this.$route.path.split('/')
|
||||
const id = arr[arr.length - 1]
|
||||
await this.$store.dispatch('spider/getFileTree', { id })
|
||||
},
|
||||
async onFileClick(data) {
|
||||
if (data.is_dir) {
|
||||
return
|
||||
}
|
||||
this.currentFilePath = data.path
|
||||
this.onItemClick(data)
|
||||
},
|
||||
onDirClick(data, node) {
|
||||
const vm = this
|
||||
setTimeout(() => {
|
||||
vm.$set(vm.nodeExpandedDict, data.path, node.expanded)
|
||||
}, 0)
|
||||
},
|
||||
sortFiles(nodes) {
|
||||
nodes.forEach(node => {
|
||||
if (node.is_dir) {
|
||||
if (!node.children) node.children = []
|
||||
node.children = this.sortFiles(node.children)
|
||||
}
|
||||
})
|
||||
return nodes.sort((a, b) => {
|
||||
if ((a.is_dir && b.is_dir) || (!a.is_dir && !b.is_dir)) {
|
||||
return a.name > b.name ? 1 : -1
|
||||
} else {
|
||||
return a.is_dir ? -1 : 1
|
||||
}
|
||||
})
|
||||
},
|
||||
filterFiles(nodes) {
|
||||
return nodes.filter(node => {
|
||||
if (node.is_dir) {
|
||||
node.children = this.filterFiles(node.children)
|
||||
}
|
||||
for (let i = 0; i < this.ignoreFileRegexList.length; i++) {
|
||||
const regex = this.ignoreFileRegexList[i]
|
||||
if (node.name.match(regex)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
isActiveFile(node) {
|
||||
return node.path === this.currentFilePath
|
||||
},
|
||||
onFileRightClick(ev, data) {
|
||||
this.isShowCreatePopoverDict = {}
|
||||
this.$set(this.isShowCreatePopoverDict, data.path, true)
|
||||
this.activeFileNode = data
|
||||
this.$st.sendEv('爬虫详情', '文件', '右键点击导航栏')
|
||||
},
|
||||
onEmptyClick() {
|
||||
const data = { path: '' }
|
||||
this.isShowCreatePopoverDict = {}
|
||||
this.$set(this.isShowCreatePopoverDict, data.path, true)
|
||||
this.activeFileNode = data
|
||||
this.$st.sendEv('爬虫详情', '文件', '空白点击添加')
|
||||
},
|
||||
onHideCreate(data) {
|
||||
this.$set(this.isShowCreatePopoverDict, data.path, false)
|
||||
this.name = ''
|
||||
},
|
||||
onClickRemoveNav(data) {
|
||||
this.$confirm(this.$t('Are you sure to delete this file/directory?'), this.$t('Notification'), {
|
||||
confirmButtonText: this.$t('Confirm'),
|
||||
cancelButtonText: this.$t('Cancel'),
|
||||
confirmButtonClass: 'danger',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.onFileDeleteNav(data.path)
|
||||
})
|
||||
},
|
||||
async onFileDeleteNav(path) {
|
||||
await this.$store.dispatch('file/deleteFile', { path })
|
||||
await this.$store.dispatch('spider/getFileTree')
|
||||
this.$message.success(this.$t('Deleted successfully'))
|
||||
this.isShowDelete = false
|
||||
this.showFile = false
|
||||
this.$st.sendEv('爬虫详情', '文件', '删除')
|
||||
},
|
||||
clickSpider(filepath) {
|
||||
const node = this.$refs['tree'].getNode(filepath)
|
||||
const data = node.data
|
||||
this.onFileClick(data)
|
||||
node.parent.expanded = true
|
||||
this.$set(this.nodeExpandedDict, node.parent.data.path, true)
|
||||
node.parent.parent.expanded = true
|
||||
this.$set(this.nodeExpandedDict, node.parent.parent.data.path, true)
|
||||
},
|
||||
clickPipeline() {
|
||||
const filename = 'pipelines.py'
|
||||
for (let i = 0; i < this.computedFileTree.length; i++) {
|
||||
const dataLv1 = this.computedFileTree[i]
|
||||
const nodeLv1 = this.$refs['tree'].getNode(dataLv1.path)
|
||||
if (dataLv1.is_dir) {
|
||||
for (let j = 0; j < dataLv1.children.length; j++) {
|
||||
const dataLv2 = dataLv1.children[j]
|
||||
if (dataLv2.path.match(filename)) {
|
||||
this.onFileClick(dataLv2)
|
||||
nodeLv1.expanded = true
|
||||
this.$set(this.nodeExpandedDict, dataLv1.path, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-list-container {
|
||||
margin-left: -15px;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
.top-part {
|
||||
display: flex;
|
||||
height: 33px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.file-path-container {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
margin: 0 10px 0 0;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #eaecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgba(3, 47, 98, 1);
|
||||
|
||||
.left {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.el-icon-back {
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
/*height: 22px;*/
|
||||
width: 100%;
|
||||
line-height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-icon-edit {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.action-container {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
/*padding: 1px 5px;*/
|
||||
/*height: 24px;*/
|
||||
|
||||
.el-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
padding: 10px;
|
||||
list-style: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
min-height: 100%;
|
||||
/*border-radius: 5px;*/
|
||||
/*border: 1px solid #eaecef;*/
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.file-path >>> .el-input__inner {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 2px solid #409EFF;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-line {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 14px;
|
||||
color: rgba(3, 47, 98, 1);
|
||||
}
|
||||
|
||||
.add-type-list {
|
||||
text-align: right;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.add-type {
|
||||
cursor: pointer;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.file-tree-wrapper {
|
||||
float: left;
|
||||
width: 240px;
|
||||
height: calc(100vh - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-tree-wrapper >>> .el-tree-node__content {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.file-tree-wrapper >>> .el-tree-node__content .item-name.active {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
float: left;
|
||||
width: calc(100% - 240px);
|
||||
height: calc(100vh - 200px);
|
||||
border-left: 1px solid #eaecef;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.create-item-popover {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.action-item-title {
|
||||
padding-top: 10px;
|
||||
padding-left: 15px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.action-item-list .action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 35px;
|
||||
padding: 0 0 0 10px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-item-list .action-item:last-child {
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
.action-item-list .action-item:hover {
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.action-item-list .action-item svg {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.action-item-list .action-item .action-item-text {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.add-btn-wrapper {
|
||||
width: 220px;
|
||||
border-top: 1px solid #eaecef;
|
||||
margin: 10px 10px;
|
||||
}
|
||||
|
||||
.add-btn-wrapper .add-btn {
|
||||
width: 80px;
|
||||
margin-left: calc(120px - 40px - 10px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/components/File/fileEditorDropZone.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {useDropzone} from 'vue3-dropzone';
|
||||
|
||||
const useFileEditorDropZone = () => {
|
||||
const onDrop = (acceptedFiles: InputFile[], rejectReasons: FileRejectReason[], event: Event) => {
|
||||
console.log(acceptedFiles);
|
||||
};
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
open,
|
||||
} = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
|
||||
return {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
open,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFileEditorDropZone;
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg
|
||||
:class="{'is-active':isActive}"
|
||||
class="hamburger"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Hamburger',
|
||||
props: {
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
toggleClick: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hamburger {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.hamburger.is-active {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div class="info-view">
|
||||
<el-row>
|
||||
<el-form
|
||||
ref="nodeForm"
|
||||
label-width="150px"
|
||||
:model="nodeForm"
|
||||
class="node-form"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item :label="$t('Node Name')">
|
||||
<el-input v-model="nodeForm.name" :placeholder="$t('Node Name')" :disabled="isView" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Node IP')" prop="ip" required>
|
||||
<el-input v-model="nodeForm.ip" :placeholder="$t('Node IP')" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Node MAC')" prop="ip" required>
|
||||
<el-input v-model="nodeForm.mac" :placeholder="$t('Node MAC')" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Description')">
|
||||
<el-input v-model="nodeForm.description" type="textarea" :placeholder="$t('Description')" :disabled="isView" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-row>
|
||||
<el-row v-if="!isView" class="button-container">
|
||||
<el-button size="small" type="success" @click="onSave">{{ $t('Save') }}</el-button>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'NodeInfoView',
|
||||
props: {
|
||||
isView: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('node', [
|
||||
'nodeForm'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
onSave() {
|
||||
this.$refs.nodeForm.validate(valid => {
|
||||
if (valid) {
|
||||
this.$store.dispatch('node/editNode')
|
||||
.then(() => {
|
||||
this.$message.success(this.$t('Node info has been saved successfully'))
|
||||
})
|
||||
}
|
||||
})
|
||||
this.$st.sendEv('节点详情', '概览', '保存')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-form {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,361 +0,0 @@
|
||||
<template>
|
||||
<div class="info-view">
|
||||
<crawl-confirm-dialog
|
||||
:visible="crawlConfirmDialogVisible"
|
||||
:spider-id="spiderForm._id"
|
||||
@close="crawlConfirmDialogVisible = false"
|
||||
/>
|
||||
|
||||
<el-row>
|
||||
<el-form
|
||||
ref="spiderForm"
|
||||
label-width="150px"
|
||||
:model="spiderForm"
|
||||
class="spider-form"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item :label="$t('Spider ID')">
|
||||
<el-input v-model="spiderForm._id" :placeholder="$t('Spider ID')" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Spider Name')">
|
||||
<el-input v-model="spiderForm.display_name" :placeholder="$t('Spider Name')" :disabled="isView || isPublic" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Project')" prop="project_id" required>
|
||||
<el-select
|
||||
v-model="spiderForm.project_id"
|
||||
:placeholder="$t('Project')"
|
||||
filterable
|
||||
:disabled="isView || isPublic"
|
||||
>
|
||||
<el-option
|
||||
v-for="p in projectList"
|
||||
:key="p._id"
|
||||
:value="p._id"
|
||||
:label="p.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Source Folder')">
|
||||
<el-input v-model="spiderForm.src" :placeholder="$t('Source Folder')" disabled />
|
||||
</el-form-item>
|
||||
<template v-if="spiderForm.type === 'customized'">
|
||||
<el-form-item :label="$t('Execute Command')" prop="cmd" required :inline-message="true">
|
||||
<el-input
|
||||
v-model="spiderForm.cmd"
|
||||
:placeholder="$t('Execute Command')"
|
||||
:disabled="isView || spiderForm.is_scrapy || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="$t('Results Collection')" prop="col">
|
||||
<el-input
|
||||
v-model="spiderForm.col"
|
||||
:placeholder="$t('By default: ') + 'results_<spider_name>'"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Spider Type')">
|
||||
<el-select v-model="spiderForm.type" :placeholder="$t('Spider Type')" :disabled="true" clearable>
|
||||
<el-option value="configurable" :label="$t('Configurable')" />
|
||||
<el-option value="customized" :label="$t('Customized')" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Remark')">
|
||||
<el-input
|
||||
v-model="spiderForm.remark"
|
||||
type="textarea"
|
||||
:placeholder="$t('Remark')"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-form-item v-if="spiderForm.type === 'customized' && !isView" :label="$t('Is Scrapy')" prop="is_scrapy">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_scrapy"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
@change="onIsScrapyChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item v-if="!isView" :label="$t('Is Git')" prop="is_git">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_git"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item v-if="!isView" :label="$t('Is Long Task')" prop="is_long_task">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_long_task"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item
|
||||
v-if="!isView"
|
||||
:label="$t('De-Duplication')"
|
||||
prop="dedup_field"
|
||||
:rules="dedupRules"
|
||||
>
|
||||
<div style="display: flex; align-items: center; height: 40px">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_dedup"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
@change="onIsDedupChange"
|
||||
/>
|
||||
<el-select
|
||||
v-if="spiderForm.is_dedup"
|
||||
v-model="spiderForm.dedup_method"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
style="margin-left: 20px; width: 180px"
|
||||
>
|
||||
<el-option value="overwrite" :label="$t('Overwrite')" />
|
||||
<el-option value="ignore" :label="$t('Ignore')" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="spiderForm.is_dedup"
|
||||
v-model="spiderForm.dedup_field"
|
||||
:placeholder="$t('Please enter de-duplicated field')"
|
||||
style="margin-left: 20px"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isView" label="Web Hook">
|
||||
<div style="display: flex; align-items: center; height: 40px">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_web_hook"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
<el-input
|
||||
v-if="spiderForm.is_web_hook"
|
||||
v-model="spiderForm.web_hook_url"
|
||||
:placeholder="$t('Please enter Web Hook URL')"
|
||||
style="margin-left: 20px"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-form-item v-if="!isView" :label="$t('Is Public')" prop="is_public">
|
||||
<el-switch
|
||||
v-model="spiderForm.is_public"
|
||||
active-color="#13ce66"
|
||||
:disabled="isView || isPublic"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-row>
|
||||
<el-row v-if="!isView" class="button-container">
|
||||
<el-button
|
||||
v-if="isShowRun && !isPublic"
|
||||
size="small"
|
||||
type="danger"
|
||||
icon="el-icon-video-play"
|
||||
style="margin-right: 10px"
|
||||
@click="onCrawl"
|
||||
>
|
||||
{{ $t('Run') }}
|
||||
</el-button>
|
||||
<el-upload
|
||||
v-if="spiderForm.type === 'customized'"
|
||||
:action="$request.baseUrl + `/spiders/${spiderForm._id}/upload`"
|
||||
:headers="{Authorization:token}"
|
||||
:on-progress="() => this.uploadLoading = true"
|
||||
:on-error="onUploadError"
|
||||
:on-success="onUploadSuccess"
|
||||
:file-list="fileList"
|
||||
style="display:inline-block;margin-right:10px"
|
||||
>
|
||||
<el-button v-if="!isPublic" v-loading="uploadLoading" size="small" type="primary" icon="el-icon-upload">
|
||||
{{ $t('Upload') }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button v-if="!isPublic" size="small" type="success" icon="el-icon-check" @click="onSave">
|
||||
{{ $t('Save') }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState,
|
||||
mapGetters
|
||||
} from 'vuex'
|
||||
import CrawlConfirmDialog from '../Common/CrawlConfirmDialog'
|
||||
|
||||
export default {
|
||||
name: 'SpiderInfoView',
|
||||
components: { CrawlConfirmDialog },
|
||||
props: {
|
||||
isView: {
|
||||
default: false,
|
||||
type: Boolean
|
||||
}
|
||||
},
|
||||
data() {
|
||||
const cronValidator = (rule, value, callback) => {
|
||||
const patArr = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
patArr.push('[/*,0-9]+')
|
||||
}
|
||||
const pat = '^' + patArr.join(' ') + '$'
|
||||
if (this.spiderForm.cron_enabled) {
|
||||
if (!value) {
|
||||
callback(new Error('cron cannot be empty'))
|
||||
} else if (!value.match(pat)) {
|
||||
callback(new Error('cron format is invalid'))
|
||||
}
|
||||
}
|
||||
callback()
|
||||
}
|
||||
const dedupValidator = (rule, value, callback) => {
|
||||
if (!this.spiderForm.is_dedup) {
|
||||
return callback()
|
||||
} else {
|
||||
if (value) {
|
||||
return callback()
|
||||
} else {
|
||||
return callback(new Error('dedup field cannot be empty'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
uploadLoading: false,
|
||||
fileList: [],
|
||||
crawlConfirmDialogVisible: false,
|
||||
cmdRule: [
|
||||
{ message: 'Execute Command should not be empty', required: true }
|
||||
],
|
||||
cronRules: [
|
||||
{ validator: cronValidator, trigger: 'blur' }
|
||||
],
|
||||
dedupRules: [
|
||||
{ validator: dedupValidator, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapGetters('user', [
|
||||
'userInfo',
|
||||
'token'
|
||||
]),
|
||||
...mapState('project', [
|
||||
'projectList'
|
||||
]),
|
||||
isConfigurable() {
|
||||
return this.spiderForm.type === 'configurable'
|
||||
},
|
||||
isShowRun() {
|
||||
if (this.spiderForm.type === 'customized') {
|
||||
return !!this.spiderForm.cmd
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
isPublic() {
|
||||
return this.spiderForm.is_public && this.spiderForm.username !== this.userInfo.username && this.userInfo.role !== 'admin'
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
// fetch project list
|
||||
await this.$store.dispatch('project/getProjectList')
|
||||
|
||||
// 兼容项目ID
|
||||
if (!this.spiderForm.project_id) {
|
||||
this.$set(this.spiderForm, 'project_id', '000000000000000000000000')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCrawl() {
|
||||
this.crawlConfirmDialogVisible = true
|
||||
this.$st.sendEv('爬虫详情', '概览', '点击运行')
|
||||
},
|
||||
onSave() {
|
||||
this.$refs['spiderForm'].validate(async valid => {
|
||||
if (!valid) return
|
||||
const res = await this.$store.dispatch('spider/editSpider')
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Spider info has been saved successfully'))
|
||||
}
|
||||
await this.$store.dispatch('spider/getSpiderData', this.$route.params.id)
|
||||
if (this.spiderForm.is_scrapy) {
|
||||
await this.$store.dispatch('spider/getSpiderScrapySpiders', this.$route.params.id)
|
||||
}
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', '概览', '保存')
|
||||
},
|
||||
fetchSiteSuggestions(keyword, callback) {
|
||||
this.$request.get('/sites', {
|
||||
keyword: keyword,
|
||||
page_num: 1,
|
||||
page_size: 100
|
||||
}).then(response => {
|
||||
const data = response.data.items.map(d => {
|
||||
d.value = `${d.name} | ${d.domain}`
|
||||
return d
|
||||
})
|
||||
callback(data)
|
||||
})
|
||||
},
|
||||
onSiteSelect(item) {
|
||||
this.spiderForm.site = item._id
|
||||
},
|
||||
onUploadSuccess() {
|
||||
this.$store.dispatch('spider/getFileTree')
|
||||
|
||||
this.uploadLoading = false
|
||||
|
||||
this.$message.success(this.$t('Uploaded spider files successfully'))
|
||||
},
|
||||
onUploadError() {
|
||||
this.uploadLoading = false
|
||||
},
|
||||
onIsScrapyChange(value) {
|
||||
if (value) {
|
||||
this.spiderForm.cmd = 'scrapy crawl'
|
||||
}
|
||||
},
|
||||
onIsDedupChange(value) {
|
||||
if (value && !this.spiderForm.dedup_method) {
|
||||
this.spiderForm.dedup_method = 'overwrite'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spider-form {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-view >>> .el-upload-list {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<div class="info-view">
|
||||
<el-row>
|
||||
<el-form
|
||||
ref="nodeForm"
|
||||
label-width="150px"
|
||||
:model="taskForm"
|
||||
class="node-form"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item :label="$t('Task ID')">
|
||||
<el-input v-model="taskForm._id" placeholder="Task ID" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Status')">
|
||||
<status-tag :status="taskForm.status" />
|
||||
<el-badge
|
||||
v-if="taskForm.error_log_count > 0"
|
||||
:value="taskForm.error_log_count"
|
||||
style="margin-left:10px; cursor:pointer;"
|
||||
>
|
||||
<el-tag type="danger" @click="onClickLogWithErrors">
|
||||
<i class="el-icon-warning" />
|
||||
{{ $t('Log with errors') }}
|
||||
</el-tag>
|
||||
</el-badge>
|
||||
<el-tag
|
||||
v-if="taskForm.type === 'spider' && taskForm.status === 'finished' && taskForm.result_count === 0"
|
||||
type="danger"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
<i class="el-icon-warning" />
|
||||
{{ $t('Empty results') }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Execute Command')">
|
||||
<el-input v-model="taskForm.cmd" placeholder="Execute Command" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Parameters')">
|
||||
<el-input v-model="taskForm.param" placeholder="Parameters" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Create Time')">
|
||||
<el-input :value="getTime(taskForm.create_ts)" placeholder="Create Time" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Start Time')">
|
||||
<el-input :value="getTime(taskForm.start_ts)" placeholder="Start Time" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Finish Time')">
|
||||
<el-input :value="getTime(taskForm.finish_ts)" placeholder="Finish Time" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Wait Duration (sec)')">
|
||||
<el-input :value="getWaitDuration(taskForm)" placeholder="Wait Duration" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Runtime Duration (sec)')">
|
||||
<el-input :value="getRuntimeDuration(taskForm)" placeholder="Runtime Duration" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Total Duration (sec)')">
|
||||
<el-input :value="getTotalDuration(taskForm)" placeholder="Runtime Duration" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Results Count')">
|
||||
<el-input v-model="taskForm.result_count" placeholder="Results Count" disabled />
|
||||
</el-form-item>
|
||||
<!--<el-form-item :label="$t('Average Results Count per Second')">-->
|
||||
<!--<el-input v-model="taskForm.avg_num_results" placeholder="Average Results Count per Second" disabled>-->
|
||||
<!--</el-input>-->
|
||||
<!--</el-form-item>-->
|
||||
<el-form-item v-if="taskForm.status === 'error'" :label="$t('Error Message')">
|
||||
<div class="error-message">
|
||||
{{ taskForm.error }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-row>
|
||||
<el-row class="button-container">
|
||||
<el-button size="small" type="warning" icon="el-icon-refresh" @click="onRestart">
|
||||
{{ $t('Restart') }}
|
||||
</el-button>
|
||||
<el-button v-if="isRunning" size="small" type="danger" icon="el-icon-video-pause" @click="onStop">
|
||||
{{ $t('Stop') }}
|
||||
</el-button>
|
||||
<!--<el-button type="danger" @click="onRestart">Restart</el-button>-->
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import StatusTag from '../Status/StatusTag'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
name: 'NodeInfoView',
|
||||
components: { StatusTag },
|
||||
computed: {
|
||||
...mapState('task', [
|
||||
'taskForm',
|
||||
'taskLog',
|
||||
'errorLogData'
|
||||
]),
|
||||
isRunning() {
|
||||
return ['pending', 'running'].includes(this.taskForm.status)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onStop() {
|
||||
this.$store.dispatch('task/cancelTask', this.$route.params.id)
|
||||
.then(() => {
|
||||
this.$message.success(`Task "${this.$route.params.id}" has been sent signal to stop`)
|
||||
})
|
||||
this.$st.sendEv('任务详情', '概览', '停止任务')
|
||||
},
|
||||
onRestart() {
|
||||
this.$confirm(this.$t('Are you sure to restart this task?'), this.$t('Notification'), {
|
||||
confirmButtonText: this.$t('Confirm'),
|
||||
cancelButtonText: this.$t('Cancel'),
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
this.$store.dispatch('task/restartTask', this.taskForm._id)
|
||||
.then(() => {
|
||||
this.$message({
|
||||
type: 'success',
|
||||
message: this.$t('Restarted successfully')
|
||||
})
|
||||
})
|
||||
this.$st.sendEv('任务详情', '概览', '重新开始任务')
|
||||
})
|
||||
},
|
||||
getTime(str) {
|
||||
if (!str || str.match('^0001')) return 'NA'
|
||||
return dayjs(str).format('YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
getWaitDuration(row) {
|
||||
if (!row.start_ts || row.start_ts.match('^0001')) return 'NA'
|
||||
return dayjs(row.start_ts).diff(row.create_ts, 'second')
|
||||
},
|
||||
getRuntimeDuration(row) {
|
||||
if (!row.finish_ts || row.finish_ts.match('^0001')) return 'NA'
|
||||
return dayjs(row.finish_ts).diff(row.start_ts, 'second')
|
||||
},
|
||||
getTotalDuration(row) {
|
||||
if (!row.finish_ts || row.finish_ts.match('^0001')) return 'NA'
|
||||
return dayjs(row.finish_ts).diff(row.create_ts, 'second')
|
||||
},
|
||||
onClickLogWithErrors() {
|
||||
this.$emit('click-log')
|
||||
this.$st.sendEv('任务详情', '概览', '点击日志错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-form {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(245, 108, 108, .1);
|
||||
color: #f56c6c;
|
||||
border: 1px solid rgba(245, 108, 108, .2);
|
||||
border-radius: 4px;
|
||||
line-height: 18px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/components/Node/CreateEditNodeDialog.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<CreateEditDialog
|
||||
:action-functions="actionFunctions"
|
||||
:batch-form-data="formList"
|
||||
:batch-form-fields="batchFormFields"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:confirm-loading="confirmLoading"
|
||||
:tab-name="createEditDialogTabName"
|
||||
:type="activeDialogKey"
|
||||
:visible="createEditDialogVisible"
|
||||
:form-rules="formRules"
|
||||
>
|
||||
<template #default>
|
||||
<NodeForm/>
|
||||
</template>
|
||||
</CreateEditDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
|
||||
import NodeForm from '@/components/node/NodeForm.vue';
|
||||
import useNode from '@/components/node/node';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CreateEditProjectDialog',
|
||||
components: {
|
||||
CreateEditDialog,
|
||||
NodeForm,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
...useNode(store),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
50
frontend/src/components/Node/NodeActive.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<el-tag :type="type" class="node-active" size="mini">
|
||||
<font-awesome-icon :icon="icon" class="icon"/>
|
||||
<span>{{ label }}</span>
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeActive',
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
setup(props: NodeActiveProps, {emit}) {
|
||||
const type = computed<string>(() => {
|
||||
const {active} = props;
|
||||
return active ? 'success' : 'info';
|
||||
});
|
||||
|
||||
const label = computed<string>(() => {
|
||||
const {active} = props;
|
||||
return active ? 'Online' : 'Offline';
|
||||
});
|
||||
|
||||
const icon = computed<string[]>(() => {
|
||||
const {active} = props;
|
||||
return active ? ['fa', 'check-circle'] : ['fa', 'times-circle'];
|
||||
});
|
||||
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
icon,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-active {
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
frontend/src/components/Node/NodeForm.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<Form
|
||||
v-if="form"
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:selective="isSelectiveForm"
|
||||
>
|
||||
<!--Row-->
|
||||
<FormItem v-if="readonly" :offset="2" :span="2" label="Key" not-editable prop="key">
|
||||
<el-input :value="form.key" disabled/>
|
||||
</FormItem>
|
||||
<!--./Row-->
|
||||
|
||||
<!--Row-->
|
||||
<FormItem :span="2" label="Name" not-editable prop="name" required>
|
||||
<el-input v-model="form.name" :disabled="isFormItemDisabled('name')" placeholder="Name"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Tags" prop="tags">
|
||||
<TagInput v-model="form.tags" :disabled="isFormItemDisabled('tags')"/>
|
||||
</FormItem>
|
||||
<!--./Row-->
|
||||
|
||||
<!--Row-->
|
||||
<FormItem :span="2" label="Type" not-editable prop="type">
|
||||
<NodeType :is-master="form.is_master"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="IP" prop="ip">
|
||||
<el-input v-model="form.ip" :disabled="isFormItemDisabled('ip')" placeholder="IP"/>
|
||||
</FormItem>
|
||||
<!--./Row-->
|
||||
|
||||
<!--Row-->
|
||||
<FormItem :span="2" label="MAC Address" prop="mac">
|
||||
<el-input v-model="form.mac" :disabled="isFormItemDisabled('mac')" placeholder="MAC Address"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Hostname" prop="hostname">
|
||||
<el-input v-model="form.hostname" :disabled="isFormItemDisabled('hostname')" placeholder="Hostname"/>
|
||||
</FormItem>
|
||||
<!--./Row-->
|
||||
|
||||
<!--Row-->
|
||||
<FormItem :span="2" label="Enabled" prop="enabled">
|
||||
<Switch v-model="form.enabled" :disabled="isFormItemDisabled('enabled')"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Max Runners" prop="max_runners">
|
||||
<el-input-number
|
||||
v-model="form.max_runners"
|
||||
:disabled="isFormItemDisabled('max_runners')"
|
||||
:min="0"
|
||||
placeholder="Max Runners"
|
||||
/>
|
||||
</FormItem>
|
||||
<!--./Row-->
|
||||
|
||||
<!--Row-->
|
||||
<FormItem :span="4" label="Description" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
:disabled="isFormItemDisabled('description')"
|
||||
placeholder="Description"
|
||||
type="textarea"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<!--./Row-->
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import useNode from '@/components/node/node';
|
||||
import TagInput from '@/components/input/TagInput.vue';
|
||||
import Form from '@/components/form/Form.vue';
|
||||
import FormItem from '@/components/form/FormItem.vue';
|
||||
import NodeType from '@/components/node/NodeType.vue';
|
||||
import Switch from '@/components/switch/Switch.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeForm',
|
||||
props: {
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Switch,
|
||||
NodeType,
|
||||
Form,
|
||||
FormItem,
|
||||
TagInput,
|
||||
},
|
||||
setup(props, {emit}) {
|
||||
// store
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
...useNode(store),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,354 +0,0 @@
|
||||
<template>
|
||||
<div class="node-installation">
|
||||
<el-form class="search-form" inline>
|
||||
<el-form-item>
|
||||
<el-autocomplete
|
||||
v-if="activeLang.executable_name === 'python'"
|
||||
v-model="depName"
|
||||
class="search-box"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
:placeholder="$t('Search Dependencies')"
|
||||
:fetch-suggestions="fetchAllDepList"
|
||||
:minlength="2"
|
||||
@select="onSearch"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="depName"
|
||||
style="width: 240px"
|
||||
:placeholder="$t('Search Dependencies')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
size="small"
|
||||
icon="el-icon-search"
|
||||
type="success"
|
||||
@click="onSearch"
|
||||
>
|
||||
{{ $t('Search') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="isShowInstalled" :label="$t('Show installed')" @change="onIsShowInstalledChange" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-tabs v-model="activeTab" @tab-click="onTabChange">
|
||||
<el-tab-pane v-for="lang in langList" :key="lang.name" :label="lang.name" :name="lang.executable_name" />
|
||||
</el-tabs>
|
||||
<template v-if="activeLang.install_status === 'installed'">
|
||||
<template v-if="!['python', 'node'].includes(activeLang.executable_name)">
|
||||
<div class="install-wrapper">
|
||||
<el-button
|
||||
icon="el-icon-check"
|
||||
disabled
|
||||
type="success"
|
||||
>
|
||||
{{ $t('Installed') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
height="calc(100vh - 280px)"
|
||||
:data="computedDepList"
|
||||
:empty-text="depName ? $t('No Data') : $t('Please search dependencies')"
|
||||
border
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Name')"
|
||||
prop="name"
|
||||
width="180"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="!isShowInstalled ? $t('Latest Version') : $t('Version')"
|
||||
prop="version"
|
||||
width="100"
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="!isShowInstalled"
|
||||
:label="$t('Description')"
|
||||
prop="description"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-if="!scope.row.installed"
|
||||
:icon="getDepLoading(scope.row) ? 'el-icon-loading' : ''"
|
||||
:disabled="getDepLoading(scope.row)"
|
||||
size="mini"
|
||||
type="primary"
|
||||
@click="onClickInstallDep(scope.row)"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
:icon="getDepLoading(scope.row) ? 'el-icon-loading' : ''"
|
||||
:disabled="getDepLoading(scope.row)"
|
||||
size="mini"
|
||||
type="danger"
|
||||
@click="onClickUninstallDep(scope.row)"
|
||||
>
|
||||
{{ $t('Uninstall') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="activeLang.install_status === 'installing'">
|
||||
<div class="install-wrapper">
|
||||
<el-button
|
||||
icon="el-icon-loading"
|
||||
disabled
|
||||
type="warning"
|
||||
>
|
||||
{{ $t('Installing') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeLang.install_status === 'installing-other'">
|
||||
<div class="install-wrapper">
|
||||
<el-button
|
||||
loading="el-icon-close"
|
||||
disabled
|
||||
type="warning"
|
||||
>
|
||||
{{ $t('Other language installing') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeLang.install_status === 'not-installed'">
|
||||
<div class="install-wrapper">
|
||||
<h4>{{ $t('This language is not installed yet.') }}</h4>
|
||||
<el-button
|
||||
icon="el-icon-check"
|
||||
type="primary"
|
||||
@click="onClickInstallLang"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'NodeInstallation',
|
||||
data() {
|
||||
return {
|
||||
activeTab: '',
|
||||
langList: [],
|
||||
depName: '',
|
||||
depList: [],
|
||||
loading: false,
|
||||
isShowInstalled: true,
|
||||
installedDepList: [],
|
||||
depLoadingDict: {},
|
||||
isLoadingInstallLang: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('node', [
|
||||
'nodeForm'
|
||||
]),
|
||||
activeLang() {
|
||||
for (let i = 0; i < this.langList.length; i++) {
|
||||
if (this.langList[i].executable_name === this.activeTab) {
|
||||
return this.langList[i]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
activeLangName() {
|
||||
return this.activeLang.executable_name
|
||||
},
|
||||
computedDepList() {
|
||||
if (this.isShowInstalled) {
|
||||
return this.installedDepList
|
||||
} else {
|
||||
return this.depList
|
||||
}
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
const arr = this.$route.path.split('/')
|
||||
const id = arr[arr.length - 1]
|
||||
const res = await this.$request.get(`/nodes/${id}/langs`)
|
||||
this.langList = res.data.data
|
||||
this.activeTab = this.langList[0].executable_name || ''
|
||||
setTimeout(() => {
|
||||
this.getInstalledDepList()
|
||||
}, 100)
|
||||
},
|
||||
methods: {
|
||||
async getDepList() {
|
||||
this.loading = true
|
||||
this.depList = []
|
||||
const res = await this.$request.get(`/nodes/${this.nodeForm._id}/deps`, {
|
||||
lang: this.activeLang.executable_name,
|
||||
dep_name: this.depName
|
||||
})
|
||||
this.loading = false
|
||||
this.depList = res.data.data
|
||||
|
||||
if (this.activeLangName === 'python') {
|
||||
// 排序
|
||||
this.depList = this.depList.sort((a, b) => a.name > b.name ? 1 : -1)
|
||||
|
||||
// 异步获取python附加信息
|
||||
this.depList.map(async dep => {
|
||||
const resp = await this.$request.get(`/system/deps/${this.activeLang.executable_name}/${dep.name}/json`)
|
||||
if (resp) {
|
||||
dep.version = resp.data.data.version
|
||||
dep.description = resp.data.data.description
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async getInstalledDepList() {
|
||||
if (this.activeLang.install_status !== 'installed') return
|
||||
if (!['Python', 'Node.js'].includes(this.activeLang.name)) return
|
||||
|
||||
this.loading = true
|
||||
this.installedDepList = []
|
||||
const res = await this.$request.get(`/nodes/${this.nodeForm._id}/deps/installed`, {
|
||||
lang: this.activeLang.executable_name
|
||||
})
|
||||
this.loading = false
|
||||
this.installedDepList = res.data.data
|
||||
},
|
||||
async fetchAllDepList(queryString, callback) {
|
||||
const res = await this.$request.get(`/system/deps/${this.activeLang.executable_name}`, {
|
||||
dep_name: queryString
|
||||
})
|
||||
callback(res.data.data ? res.data.data.map(d => {
|
||||
return { value: d, label: d }
|
||||
}) : [])
|
||||
},
|
||||
onSearch() {
|
||||
this.isShowInstalled = false
|
||||
this.getDepList()
|
||||
this.$st.sendEv('节点详情', '安装', '搜索依赖')
|
||||
},
|
||||
onIsShowInstalledChange(val) {
|
||||
if (val) {
|
||||
this.getInstalledDepList()
|
||||
} else {
|
||||
this.depName = ''
|
||||
this.depList = []
|
||||
}
|
||||
this.$st.sendEv('节点详情', '安装', '点击查看已安装')
|
||||
},
|
||||
async onClickInstallDep(dep) {
|
||||
const name = dep.name
|
||||
this.$set(this.depLoadingDict, name, true)
|
||||
const arr = this.$route.path.split('/')
|
||||
const id = arr[arr.length - 1]
|
||||
const data = await this.$request.post(`/nodes/${id}/deps/install`, {
|
||||
lang: this.activeLang.executable_name,
|
||||
dep_name: name
|
||||
})
|
||||
if (!data || data.error) {
|
||||
this.$notify.error({
|
||||
title: this.$t('Installing dependency failed'),
|
||||
message: this.$t('The dependency installation is unsuccessful: ') + name
|
||||
})
|
||||
} else {
|
||||
this.$notify.success({
|
||||
title: this.$t('Installing dependency successful'),
|
||||
message: this.$t('You have successfully installed a dependency: ') + name
|
||||
})
|
||||
dep.installed = true
|
||||
}
|
||||
this.$request.put('/actions', {
|
||||
type: 'install_dep'
|
||||
})
|
||||
this.$set(this.depLoadingDict, name, false)
|
||||
this.$st.sendEv('节点详情', '安装', '安装依赖')
|
||||
},
|
||||
async onClickUninstallDep(dep) {
|
||||
const name = dep.name
|
||||
this.$set(this.depLoadingDict, name, true)
|
||||
const arr = this.$route.path.split('/')
|
||||
const id = arr[arr.length - 1]
|
||||
const data = await this.$request.post(`/nodes/${id}/deps/uninstall`, {
|
||||
lang: this.activeLang.executable_name,
|
||||
dep_name: name
|
||||
})
|
||||
if (!data || data.error) {
|
||||
this.$notify.error({
|
||||
title: this.$t('Uninstalling dependency failed'),
|
||||
message: this.$t('The dependency uninstallation is unsuccessful: ') + name
|
||||
})
|
||||
} else {
|
||||
this.$notify.success({
|
||||
title: this.$t('Uninstalling dependency successful'),
|
||||
message: this.$t('You have successfully uninstalled a dependency: ') + name
|
||||
})
|
||||
dep.installed = false
|
||||
}
|
||||
this.$set(this.depLoadingDict, name, false)
|
||||
this.$st.sendEv('节点详情', '安装', '卸载依赖')
|
||||
},
|
||||
getDepLoading(dep) {
|
||||
const name = dep.name
|
||||
if (this.depLoadingDict[name] === undefined) {
|
||||
return false
|
||||
}
|
||||
return this.depLoadingDict[name]
|
||||
},
|
||||
async onClickInstallLang() {
|
||||
this.isLoadingInstallLang = true
|
||||
const res = await this.$request.post(`/nodes/${this.nodeForm._id}/langs/install`, {
|
||||
lang: this.activeLang.executable_name
|
||||
})
|
||||
if (!res || res.error) {
|
||||
this.$notify.error({
|
||||
title: this.$t('Installing language failed'),
|
||||
message: this.$t('The language installation is unsuccessful: ') + this.activeLang.name
|
||||
})
|
||||
} else {
|
||||
this.$notify.success({
|
||||
title: this.$t('Installing language successful'),
|
||||
message: this.$t('You have successfully installed a language: ') + this.activeLang.name
|
||||
})
|
||||
}
|
||||
this.$request.put('/actions', {
|
||||
type: 'install_lang'
|
||||
})
|
||||
this.isLoadingInstallLang = false
|
||||
this.$st.sendEv('节点详情', '安装', '安装语言')
|
||||
},
|
||||
onTabChange() {
|
||||
if (this.isShowInstalled) {
|
||||
this.getInstalledDepList()
|
||||
} else {
|
||||
this.depName = ''
|
||||
this.depList = []
|
||||
}
|
||||
this.$st.sendEv('节点详情', '安装', '切换标签')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-installation >>> .install-wrapper .el-button {
|
||||
min-width: 240px;
|
||||
font-weight: bolder;
|
||||
font-size: 18px
|
||||
}
|
||||
</style>
|
||||
@@ -1,573 +0,0 @@
|
||||
<template>
|
||||
<div class="node-installation-matrix">
|
||||
<el-tabs v-model="activeTabName">
|
||||
<el-tab-pane :label="$t('Languages')" name="lang">
|
||||
<div class="lang-table">
|
||||
<el-table
|
||||
class="table"
|
||||
:data="nodeList"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white',height:'50px'}"
|
||||
border
|
||||
@row-click="onLangTableRowClick"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Node')"
|
||||
width="240px"
|
||||
prop="name"
|
||||
fixed
|
||||
/>
|
||||
<el-table-column
|
||||
:label="$t('nodeList.type')"
|
||||
width="120px"
|
||||
fixed
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.is_master" type="primary">{{ $t('Master') }}</el-tag>
|
||||
<el-tag v-else type="warning">{{ $t('Worker') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Status')"
|
||||
width="120px"
|
||||
fixed
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.status === 'offline'" type="info">{{ $t('Offline') }}</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === 'online'" type="success">{{ $t('Online') }}</el-tag>
|
||||
<el-tag v-else type="danger">{{ $t('Unavailable') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="l in langs"
|
||||
:key="l.name"
|
||||
:label="l.label"
|
||||
width="220px"
|
||||
>
|
||||
<template slot="header" slot-scope="scope">
|
||||
<div class="header-with-action">
|
||||
<span>{{ scope.column.label }}</span>
|
||||
<el-button type="primary" size="mini" @click="onInstallLangAll(scope.column.label, $event)">
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<template v-if="getLangInstallStatus(scope.row._id, l.name) === 'installed'">
|
||||
<el-tag type="success">
|
||||
<i class="el-icon-check" />
|
||||
{{ $t('Installed') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else-if="getLangInstallStatus(scope.row._id, l.name) === 'installing'">
|
||||
<el-tag type="warning">
|
||||
<i class="el-icon-loading" />
|
||||
{{ $t('Installing') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="['installing-other', 'not-installed'].includes(getLangInstallStatus(scope.row._id, l.name))"
|
||||
>
|
||||
<div class="cell-with-action">
|
||||
<el-tag type="danger">
|
||||
<i class="el-icon-error" />
|
||||
{{ $t('Not Installed') }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
@click="onInstallLang(scope.row._id, scope.column.label, $event)"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="getLangInstallStatus(scope.row._id, l.name) === 'na'">
|
||||
<el-tag type="info">
|
||||
<i class="el-icon-question" />
|
||||
{{ $t('N/A') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('Dependencies')" name="dep">
|
||||
<el-form class="search-form" inline>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="depName"
|
||||
style="width: 240px"
|
||||
:placeholder="$t('Search Dependencies')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
size="small"
|
||||
icon="el-icon-search"
|
||||
type="success"
|
||||
@click="onSearch"
|
||||
>
|
||||
{{ $t('Search') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="isShowInstalled" :label="$t('Show installed')" @change="onIsShowInstalledChange" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-tabs v-model="activeLang">
|
||||
<el-tab-pane
|
||||
v-for="l in langsWithDeps"
|
||||
:key="l.name"
|
||||
:name="l.name"
|
||||
:label="l.label"
|
||||
/>
|
||||
</el-tabs>
|
||||
<el-table
|
||||
v-loading="isDepsLoading"
|
||||
class="table"
|
||||
height="calc(100vh - 320px)"
|
||||
:data="computedDepsSet"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white',height:'50px'}"
|
||||
border
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Dependency')"
|
||||
prop="name"
|
||||
width="180px"
|
||||
fixed
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="false"
|
||||
:label="$t('Install on All Nodes')"
|
||||
width="120px"
|
||||
align="center"
|
||||
fixed
|
||||
>
|
||||
<template>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="n in activeNodes"
|
||||
:key="n._id"
|
||||
:label="n.name"
|
||||
width="220px"
|
||||
align="center"
|
||||
>
|
||||
<template slot="header" slot-scope="scope">
|
||||
{{ scope.column.label }}
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<div
|
||||
v-if="getDepStatus(n, scope.row) === 'installed'"
|
||||
class="cell-with-action"
|
||||
>
|
||||
<el-tag type="success">
|
||||
{{ $t('Installed') }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="danger"
|
||||
@click="uninstallDep(n, scope.row)"
|
||||
>
|
||||
{{ $t('Uninstall') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="getDepStatus(n, scope.row) === 'installing'"
|
||||
class="cell-with-action"
|
||||
>
|
||||
<el-tag type="warning">
|
||||
<i class="el-icon-loading" />
|
||||
{{ $t('Installing') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="getDepStatus(n, scope.row) === 'uninstalled'"
|
||||
class="cell-with-action"
|
||||
>
|
||||
<el-tag type="danger">
|
||||
{{ $t('Not Installed') }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="primary"
|
||||
@click="installDep(n, scope.row)"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Web Driver" name="webdriver">
|
||||
<div class="webdriver-table">
|
||||
<el-table
|
||||
class="table"
|
||||
:data="nodeList"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white',height:'50px'}"
|
||||
border
|
||||
@row-click="onLangTableRowClick"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Node')"
|
||||
width="240px"
|
||||
prop="name"
|
||||
fixed
|
||||
/>
|
||||
<el-table-column
|
||||
:label="$t('nodeList.type')"
|
||||
width="120px"
|
||||
fixed
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.is_master" type="primary">{{ $t('Master') }}</el-tag>
|
||||
<el-tag v-else type="warning">{{ $t('Worker') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Status')"
|
||||
width="120px"
|
||||
fixed
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.status === 'offline'" type="info">{{ $t('Offline') }}</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === 'online'" type="success">{{ $t('Online') }}</el-tag>
|
||||
<el-tag v-else type="danger">{{ $t('Unavailable') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="wd in webdrivers"
|
||||
:key="wd.name"
|
||||
:label="wd.label"
|
||||
width="220px"
|
||||
>
|
||||
<template slot="header" slot-scope="scope">
|
||||
<div class="header-with-action">
|
||||
<span>{{ scope.column.label }}</span>
|
||||
<el-button type="primary" size="mini" @click="onInstallLangAll(scope.column.label, $event)">
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<template v-if="getLangInstallStatus(scope.row._id, wd.name) === 'installed'">
|
||||
<el-tag type="success">
|
||||
<i class="el-icon-check" />
|
||||
{{ $t('Installed') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else-if="getLangInstallStatus(scope.row._id, wd.name) === 'installing'">
|
||||
<el-tag type="warning">
|
||||
<i class="el-icon-loading" />
|
||||
{{ $t('Installing') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="['installing-other', 'not-installed'].includes(getLangInstallStatus(scope.row._id, wd.name))"
|
||||
>
|
||||
<div class="cell-with-action">
|
||||
<el-tag type="danger">
|
||||
<i class="el-icon-error" />
|
||||
{{ $t('Not Installed') }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
@click="onInstallLang(scope.row._id, scope.column.label, $event)"
|
||||
>
|
||||
{{ $t('Install') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="getLangInstallStatus(scope.row._id, wd.name) === 'na'">
|
||||
<el-tag type="info">
|
||||
<i class="el-icon-question" />
|
||||
{{ $t('N/A') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'NodeInstallationMatrix',
|
||||
props: {
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
allLangs: [
|
||||
// 语言
|
||||
{ label: 'Python', name: 'python', hasDeps: true, script: 'install-python.sh', type: 'lang' },
|
||||
{ label: 'Node.js', name: 'node', hasDeps: true, script: 'install-nodejs.sh', type: 'lang' },
|
||||
{ label: 'Java', name: 'java', hasDeps: false, script: 'install-java.sh', type: 'lang' },
|
||||
{ label: '.Net Core', name: 'dotnet', hasDeps: false, script: 'install-dotnet.sh', type: 'lang' },
|
||||
{ label: 'PHP', name: 'php', hasDeps: false, script: 'install-php.sh', type: 'lang' },
|
||||
{ label: 'Golang', name: 'go', hasDeps: false, script: 'install-go.sh', type: 'lang' },
|
||||
// web driver
|
||||
{ label: 'Chrome Driver', name: 'chromedriver', script: 'install-chromedriver.sh', type: 'webdriver' },
|
||||
{ label: 'Firefox', name: 'firefox', script: 'install-firefox.sh', type: 'webdriver' }
|
||||
],
|
||||
langsDataDict: {},
|
||||
handle: undefined,
|
||||
activeTabName: 'lang',
|
||||
depsDataDict: {},
|
||||
depsSet: new Set(),
|
||||
activeLang: 'python',
|
||||
isDepsLoading: false,
|
||||
depName: '',
|
||||
isShowInstalled: true,
|
||||
depList: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('node', [
|
||||
'nodeList'
|
||||
]),
|
||||
langs() {
|
||||
return this.allLangs.filter(d => d.type === 'lang')
|
||||
},
|
||||
webdrivers() {
|
||||
return this.allLangs.filter(d => d.type === 'webdriver')
|
||||
},
|
||||
activeNodes() {
|
||||
return this.nodeList.filter(d => d.status === 'online')
|
||||
},
|
||||
computedDepsSet() {
|
||||
return Array.from(this.depsSet).map(d => {
|
||||
return {
|
||||
name: d
|
||||
}
|
||||
})
|
||||
},
|
||||
langsWithDeps() {
|
||||
return this.allLangs.filter(l => l.hasDeps)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeLang() {
|
||||
this.getDepsData()
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
setTimeout(() => {
|
||||
this.getLangsData()
|
||||
this.getDepsData()
|
||||
}, 1000)
|
||||
|
||||
this.handle = setInterval(() => {
|
||||
this.getLangsData()
|
||||
}, 10000)
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.handle)
|
||||
},
|
||||
methods: {
|
||||
async getLangsData() {
|
||||
await Promise.all(this.nodeList.map(async n => {
|
||||
if (n.status !== 'online') return
|
||||
const res = await this.$request.get(`/nodes/${n._id}/langs`)
|
||||
if (!res.data.data) return
|
||||
res.data.data.forEach(l => {
|
||||
const key = n._id + '|' + l.executable_name
|
||||
this.$set(this.langsDataDict, key, l)
|
||||
})
|
||||
}))
|
||||
},
|
||||
async getDepsData() {
|
||||
this.isDepsLoading = true
|
||||
this.depsDataDict = {}
|
||||
this.depsSet = new Set()
|
||||
const depsSet = new Set()
|
||||
await Promise.all(this.nodeList.map(async n => {
|
||||
if (n.status !== 'online') return
|
||||
const res = await this.$request.get(`/nodes/${n._id}/deps/installed`, { lang: this.activeLang })
|
||||
if (!res.data.data) return
|
||||
res.data.data.forEach(d => {
|
||||
depsSet.add(d.name)
|
||||
const key = n._id + '|' + d.name
|
||||
this.$set(this.depsDataDict, key, 'installed')
|
||||
})
|
||||
}))
|
||||
this.depsSet = depsSet
|
||||
this.isDepsLoading = false
|
||||
},
|
||||
getLang(nodeId, langName) {
|
||||
const key = nodeId + '|' + langName
|
||||
return this.langsDataDict[key]
|
||||
},
|
||||
getLangInstallStatus(nodeId, langName) {
|
||||
const lang = this.getLang(nodeId, langName)
|
||||
if (!lang || !lang.install_status) return 'na'
|
||||
return lang.install_status
|
||||
},
|
||||
getLangFromLabel(label) {
|
||||
for (let i = 0; i < this.allLangs.length; i++) {
|
||||
const lang = this.allLangs[i]
|
||||
if (lang.label === label) {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
},
|
||||
async onInstallLang(nodeId, langLabel, ev) {
|
||||
if (ev) {
|
||||
ev.stopPropagation()
|
||||
}
|
||||
const lang = this.getLangFromLabel(langLabel)
|
||||
const res = await this.$request.put('/system-tasks', {
|
||||
run_type: 'selected-nodes',
|
||||
node_ids: [nodeId],
|
||||
script: lang.script
|
||||
})
|
||||
if (res && res.data && !res.data.error) {
|
||||
this.$message.success(this.$t('Started to install') + ' ' + lang.label)
|
||||
}
|
||||
const key = nodeId + '|' + lang.name
|
||||
this.$set(this.langsDataDict[key], 'install_status', 'installing')
|
||||
setTimeout(() => {
|
||||
this.getLangsData()
|
||||
}, 1000)
|
||||
this.$st.sendEv('节点列表', '安装', '安装语言')
|
||||
},
|
||||
async onInstallLangAll(langLabel, ev) {
|
||||
ev.stopPropagation()
|
||||
this.nodeList
|
||||
.filter(n => {
|
||||
if (n.status !== 'online') return false
|
||||
const lang = this.getLangFromLabel(langLabel)
|
||||
const key = n._id + '|' + lang.name
|
||||
if (!this.langsDataDict[key]) return false
|
||||
if (['installing', 'installed'].includes(this.langsDataDict[key].install_status)) return false
|
||||
return true
|
||||
})
|
||||
.forEach(n => {
|
||||
this.onInstallLang(n._id, langLabel, ev)
|
||||
})
|
||||
setTimeout(() => {
|
||||
this.getLangsData()
|
||||
}, 1000)
|
||||
this.$st.sendEv('节点列表', '安装', '安装语言-所有节点')
|
||||
},
|
||||
onLangTableRowClick(row) {
|
||||
this.$router.push(`/nodes/${row._id}`)
|
||||
this.$st.sendEv('节点列表', '安装', '查看节点详情')
|
||||
},
|
||||
getDepStatus(node, dep) {
|
||||
const key = node._id + '|' + dep.name
|
||||
if (!this.depsDataDict[key]) {
|
||||
return 'uninstalled'
|
||||
} else {
|
||||
return this.depsDataDict[key]
|
||||
}
|
||||
},
|
||||
async installDep(node, dep) {
|
||||
const key = node._id + '|' + dep.name
|
||||
this.$set(this.depsDataDict, key, 'installing')
|
||||
const data = await this.$request.post(`/nodes/${node._id}/deps/install`, {
|
||||
lang: this.activeLang,
|
||||
dep_name: dep.name
|
||||
})
|
||||
if (!data || data.error) {
|
||||
this.$notify.error({
|
||||
title: this.$t('Installing dependency failed'),
|
||||
message: this.$t('The dependency installation is unsuccessful: ') + dep.name
|
||||
})
|
||||
this.$set(this.depsDataDict, key, 'uninstalled')
|
||||
} else {
|
||||
this.$notify.success({
|
||||
title: this.$t('Installing dependency successful'),
|
||||
message: this.$t('You have successfully installed a dependency: ') + dep.name
|
||||
})
|
||||
this.$set(this.depsDataDict, key, 'installed')
|
||||
}
|
||||
this.$request.put('/actions', {
|
||||
type: 'install_dep'
|
||||
})
|
||||
this.$st.sendEv('节点列表', '安装', '安装依赖')
|
||||
},
|
||||
async uninstallDep(node, dep) {
|
||||
const key = node._id + '|' + dep.name
|
||||
this.$set(this.depsDataDict, key, 'installing')
|
||||
const data = await this.$request.post(`/nodes/${node._id}/deps/uninstall`, {
|
||||
lang: this.activeLang,
|
||||
dep_name: dep.name
|
||||
})
|
||||
if (!data || data.error) {
|
||||
this.$notify.error({
|
||||
title: this.$t('Uninstalling dependency failed'),
|
||||
message: this.$t('The dependency uninstallation is unsuccessful: ') + dep.name
|
||||
})
|
||||
this.$set(this.depsDataDict, key, 'installed')
|
||||
} else {
|
||||
this.$notify.success({
|
||||
title: this.$t('Uninstalling dependency successful'),
|
||||
message: this.$t('You have successfully uninstalled a dependency: ') + dep.name
|
||||
})
|
||||
this.$set(this.depsDataDict, key, 'uninstalled')
|
||||
}
|
||||
this.$st.sendEv('节点列表', '安装', '卸载依赖')
|
||||
},
|
||||
onSearch() {
|
||||
this.isShowInstalled = false
|
||||
this.getDepList()
|
||||
this.$st.sendEv('节点列表', '安装', '搜索依赖')
|
||||
},
|
||||
async getDepList() {
|
||||
const masterNode = this.nodeList.filter(n => n.is_master)[0]
|
||||
this.depsSet = []
|
||||
this.isDepsLoading = true
|
||||
const res = await this.$request.get(`/nodes/${masterNode._id}/deps`, {
|
||||
lang: this.activeLang,
|
||||
dep_name: this.depName
|
||||
})
|
||||
this.isDepsLoading = false
|
||||
this.depsSet = new Set(res.data.data.map(d => d.name))
|
||||
},
|
||||
onIsShowInstalledChange(val) {
|
||||
if (val) {
|
||||
this.getDepsData()
|
||||
} else {
|
||||
this.depsSet = []
|
||||
}
|
||||
this.$st.sendEv('节点列表', '安装', '点击查看已安装')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table {
|
||||
margin-top: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.el-table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-table .header-with-action,
|
||||
.el-table .cell-with-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<div id="network-chart"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'NodeNetwork',
|
||||
props: {
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: undefined
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
masterNode() {
|
||||
const nodes = this.$store.state.node.nodeList
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].is_master) {
|
||||
return nodes[i]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
nodes() {
|
||||
let nodes = this.$store.state.node.nodeList
|
||||
nodes = nodes
|
||||
.filter(d => d.status !== 'offline')
|
||||
.map(d => {
|
||||
d.id = d._id
|
||||
d.x = Math.floor(100 * Math.random())
|
||||
d.y = Math.floor(100 * Math.random())
|
||||
d.itemStyle = {
|
||||
color: d.is_master ? '#409EFF' : '#e6a23c'
|
||||
}
|
||||
return d
|
||||
})
|
||||
|
||||
// mongodb
|
||||
nodes.push({
|
||||
id: 'mongodb',
|
||||
name: 'MongoDB',
|
||||
x: Math.floor(100 * Math.random()),
|
||||
y: Math.floor(100 * Math.random()),
|
||||
itemStyle: {
|
||||
color: '#67c23a'
|
||||
}
|
||||
})
|
||||
|
||||
// redis
|
||||
nodes.push({
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
x: Math.floor(100 * Math.random()),
|
||||
y: Math.floor(100 * Math.random()),
|
||||
itemStyle: {
|
||||
color: '#f56c6c'
|
||||
}
|
||||
})
|
||||
|
||||
return nodes
|
||||
},
|
||||
links() {
|
||||
const links = []
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
if (this.nodes[i].status === 'offline') continue
|
||||
if (['redis', 'mongodb'].includes(this.nodes[i].id)) continue
|
||||
// mongodb
|
||||
links.push({
|
||||
source: this.nodes[i].id,
|
||||
target: 'mongodb',
|
||||
value: 10,
|
||||
lineStyle: {
|
||||
color: '#67c23a'
|
||||
}
|
||||
})
|
||||
|
||||
// redis
|
||||
links.push({
|
||||
source: this.nodes[i].id,
|
||||
target: 'redis',
|
||||
value: 10,
|
||||
lineStyle: {
|
||||
color: '#f56c6c'
|
||||
}
|
||||
})
|
||||
|
||||
if (this.masterNode.id === this.nodes[i].id) continue
|
||||
|
||||
// master
|
||||
// links.push({
|
||||
// source: this.masterNode.id,
|
||||
// target: this.nodes[i].id,
|
||||
// value: 0.5,
|
||||
// lineStyle: {
|
||||
// color: '#409EFF'
|
||||
// }
|
||||
// })
|
||||
}
|
||||
return links
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeTab() {
|
||||
setTimeout(() => {
|
||||
this.render()
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.render()
|
||||
},
|
||||
methods: {
|
||||
render() {
|
||||
const option = {
|
||||
title: {
|
||||
text: this.$t('Node Network')
|
||||
},
|
||||
tooltip: {
|
||||
formatter: params => {
|
||||
if (!params.data.name) return
|
||||
let str = '<span style="margin-right:5px;display:inline-block;height:12px;width:12px;border-radius:6px;background:' + params.color + '"></span>'
|
||||
if (params.data.name) str += '<span>' + params.data.name + '</span><br>'
|
||||
if (params.data.ip) str += '<span>IP: ' + params.data.ip + '</span><br>'
|
||||
if (params.data.mac) str += '<span>MAC: ' + params.data.mac + '</span><br>'
|
||||
return str
|
||||
}
|
||||
},
|
||||
animationDurationUpdate: 1500,
|
||||
animationEasingUpdate: 'quinticInOut',
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
symbolSize: 50,
|
||||
roam: true,
|
||||
label: {
|
||||
normal: {
|
||||
show: true
|
||||
}
|
||||
},
|
||||
edgeSymbol: ['circle', 'arrow'],
|
||||
edgeSymbolSize: [4, 10],
|
||||
edgeLabel: {
|
||||
normal: {
|
||||
textStyle: {
|
||||
fontSize: 20
|
||||
}
|
||||
}
|
||||
},
|
||||
focusOneNodeAdjacency: true,
|
||||
force: {
|
||||
initLayout: 'force',
|
||||
repulsion: 30,
|
||||
gravity: 0.001,
|
||||
edgeLength: 30
|
||||
},
|
||||
draggable: true,
|
||||
data: this.nodes,
|
||||
links: this.links,
|
||||
lineStyle: {
|
||||
normal: {
|
||||
opacity: 0.9,
|
||||
width: 2,
|
||||
curveness: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
this.chart = echarts.init(this.$el)
|
||||
this.chart.setOption(option)
|
||||
this.chart.resize()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#network-chart {
|
||||
height: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
99
frontend/src/components/Node/NodeRunners.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<Tag
|
||||
:key="data"
|
||||
:icon="data.icon"
|
||||
:label="data.label"
|
||||
:size="size"
|
||||
:spinning="data.spinning"
|
||||
:tooltip="data.tooltip"
|
||||
:type="data.type"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import Tag from '@/components/tag/Tag.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeRunners',
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
props: {
|
||||
available: {
|
||||
type: Number as PropType<number | undefined>,
|
||||
required: false,
|
||||
},
|
||||
max: {
|
||||
type: Number as PropType<number | undefined>,
|
||||
required: false,
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<BasicSize>,
|
||||
required: false,
|
||||
default: 'mini',
|
||||
}
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props: NodeRunnersProps, {emit}) {
|
||||
const running = computed<number>(() => {
|
||||
const {available, max} = props;
|
||||
if (available === undefined ||
|
||||
max === undefined ||
|
||||
isNaN(available) ||
|
||||
isNaN(max)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return (max - available) as number;
|
||||
});
|
||||
|
||||
const label = computed<string>(() => {
|
||||
const {max} = props;
|
||||
return `${running.value} / ${max}`;
|
||||
});
|
||||
|
||||
const data = computed<TagData>(() => {
|
||||
const max = props.max as number;
|
||||
if (running.value === max) {
|
||||
return {
|
||||
label: label.value,
|
||||
tooltip: 'No runners available at this moment',
|
||||
type: 'danger',
|
||||
icon: ['fa', 'ban'],
|
||||
};
|
||||
} else if (running.value > 0) {
|
||||
return {
|
||||
label: label.value,
|
||||
tooltip: `${running.value} out of ${max} runners are running`,
|
||||
type: 'warning',
|
||||
icon: ['far', 'check-square'],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: label.value,
|
||||
tooltip: `All runners available`,
|
||||
type: 'success',
|
||||
icon: ['far', 'check-square'],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
label,
|
||||
data,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-runners {
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/components/Node/NodeStatus.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<Tag
|
||||
:key="data"
|
||||
:icon="data.icon"
|
||||
:label="data.label"
|
||||
:size="size"
|
||||
:spinning="data.spinning"
|
||||
:tooltip="data.tooltip"
|
||||
:type="data.type"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import Tag from '@/components/tag/Tag.vue';
|
||||
import {
|
||||
NODE_STATUS_OFFLINE,
|
||||
NODE_STATUS_ONLINE,
|
||||
NODE_STATUS_REGISTERED,
|
||||
NODE_STATUS_UNREGISTERED
|
||||
} from '@/constants/node';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeStatus',
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
props: {
|
||||
status: {
|
||||
type: String as PropType<TaskStatus>,
|
||||
required: false,
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<BasicSize>,
|
||||
required: false,
|
||||
default: 'mini',
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props: NodeStatusProps, {emit}) {
|
||||
const data = computed<TagData>(() => {
|
||||
const {status} = props;
|
||||
switch (status) {
|
||||
case NODE_STATUS_UNREGISTERED:
|
||||
return {
|
||||
label: 'Unregistered',
|
||||
tooltip: 'Node is waiting to be registered',
|
||||
type: 'danger',
|
||||
icon: ['fa', 'exclamation'],
|
||||
};
|
||||
case NODE_STATUS_REGISTERED:
|
||||
return {
|
||||
label: 'Registered',
|
||||
tooltip: 'Node is registered and wait to be online',
|
||||
type: 'warning',
|
||||
icon: ['far', 'check-square'],
|
||||
};
|
||||
case NODE_STATUS_ONLINE:
|
||||
return {
|
||||
label: 'Online',
|
||||
tooltip: 'Node is currently online',
|
||||
type: 'success',
|
||||
icon: ['fa', 'check'],
|
||||
};
|
||||
case NODE_STATUS_OFFLINE:
|
||||
return {
|
||||
label: 'Offline',
|
||||
tooltip: 'Node is currently offline',
|
||||
type: 'info',
|
||||
icon: ['fa', 'times'],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
tooltip: 'Unknown node status',
|
||||
type: 'info',
|
||||
icon: ['fa', 'question'],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-status {
|
||||
width: 80px;
|
||||
cursor: default;
|
||||
|
||||
.icon {
|
||||
width: 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/components/Node/NodeType.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<el-tag :type="type" class="node-type" size="mini">
|
||||
<font-awesome-icon :icon="icon" class="icon"/>
|
||||
<span>{{ computedLabel }}</span>
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NodeType',
|
||||
props: {
|
||||
isMaster: {
|
||||
type: Boolean,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
setup(props: NodeTypeProps, {emit}) {
|
||||
const type = computed<string>(() => {
|
||||
const {isMaster} = props;
|
||||
return isMaster ? 'primary' : 'warning';
|
||||
});
|
||||
|
||||
const computedLabel = computed<string>(() => {
|
||||
const {isMaster, label} = props;
|
||||
if (label) return label;
|
||||
return isMaster ? 'Master' : 'Worker';
|
||||
});
|
||||
|
||||
const icon = computed<string[]>(() => {
|
||||
const {isMaster} = props;
|
||||
return isMaster ? ['fa', 'home'] : ['fa', 'server'];
|
||||
});
|
||||
|
||||
return {
|
||||
type,
|
||||
computedLabel,
|
||||
icon,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-type {
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/components/Node/node.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {readonly} from 'vue';
|
||||
import {Store} from 'vuex';
|
||||
import useForm from '@/components/form/form';
|
||||
import useNodeService from '@/services/node/nodeService';
|
||||
import {getDefaultFormComponentData} from '@/utils/form';
|
||||
import {FORM_FIELD_TYPE_INPUT, FORM_FIELD_TYPE_INPUT_TEXTAREA, FORM_FIELD_TYPE_SWITCH} from '@/constants/form';
|
||||
|
||||
type Node = CNode;
|
||||
|
||||
// get new node
|
||||
export const getNewNode = (): Node => {
|
||||
return {
|
||||
tags: [],
|
||||
max_runners: 8,
|
||||
enabled: true,
|
||||
};
|
||||
};
|
||||
|
||||
// form component data
|
||||
const formComponentData = getDefaultFormComponentData<Node>(getNewNode);
|
||||
|
||||
const useNode = (store: Store<RootStoreState>) => {
|
||||
// store
|
||||
const ns = 'node';
|
||||
const {node: state} = store.state as RootStoreState;
|
||||
|
||||
// batch form fields
|
||||
const batchFormFields: FormTableField[] = [
|
||||
{
|
||||
prop: 'name',
|
||||
label: 'Name',
|
||||
width: '150',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT,
|
||||
required: true,
|
||||
placeholder: 'Name',
|
||||
},
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: 'Enabled',
|
||||
width: '120',
|
||||
fieldType: FORM_FIELD_TYPE_SWITCH,
|
||||
},
|
||||
{
|
||||
prop: 'description',
|
||||
label: 'Description',
|
||||
width: '200',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_TEXTAREA,
|
||||
},
|
||||
];
|
||||
|
||||
// form rules
|
||||
const formRules = readonly<FormRules>({});
|
||||
|
||||
return {
|
||||
...useForm(ns, store, useNodeService(store), formComponentData),
|
||||
batchFormFields,
|
||||
formRules,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNode;
|
||||
@@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<!--last tasks-->
|
||||
<el-row class="latest-tasks-wrapper">
|
||||
<task-table-view :title="$t('Latest Tasks')" />
|
||||
</el-row>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-row class="node-info-view-wrapper">
|
||||
<!--basic info-->
|
||||
<node-info-view />
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import TaskTableView from '../TableView/TaskTableView'
|
||||
import NodeInfoView from '../InfoView/NodeInfoView'
|
||||
|
||||
export default {
|
||||
name: 'NodeOverview',
|
||||
components: {
|
||||
NodeInfoView,
|
||||
TaskTableView
|
||||
},
|
||||
computed: {
|
||||
id() {
|
||||
return this.$route.params.id
|
||||
},
|
||||
...mapState('node', [
|
||||
'nodeForm'
|
||||
])
|
||||
},
|
||||
created() {
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 10px 0 3px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<!--last tasks-->
|
||||
<el-row>
|
||||
<task-table-view :title="$t('Latest Tasks')" />
|
||||
</el-row>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<!--basic info-->
|
||||
<spider-info-view />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import TaskTableView from '../TableView/TaskTableView'
|
||||
import SpiderInfoView from '../InfoView/SpiderInfoView'
|
||||
|
||||
export default {
|
||||
name: 'SpiderOverview',
|
||||
components: {
|
||||
SpiderInfoView,
|
||||
TaskTableView
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// spiderForm: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
id() {
|
||||
return this.$route.params.id
|
||||
},
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('deploy', [
|
||||
'deployList'
|
||||
])
|
||||
},
|
||||
created() {
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 10px 0 3px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<div class="task-overview">
|
||||
<el-row class="action-wrapper">
|
||||
<el-button
|
||||
v-if="taskForm.type === 'spider'"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="el-icon-position"
|
||||
@click="onNavigateToSpider"
|
||||
>
|
||||
{{ $t('Navigate to Spider') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
icon="el-icon-position"
|
||||
@click="onNavigateToNode"
|
||||
>
|
||||
{{ $t('Navigate to Node') }}
|
||||
</el-button>
|
||||
</el-row>
|
||||
<el-row class="content">
|
||||
<el-col :span="12" style="padding-right: 20px;">
|
||||
<el-row class="task-info-overview-wrapper wrapper">
|
||||
<h4 class="title">{{ $t('Task Info') }}</h4>
|
||||
<task-info-view @click-log="() => $emit('click-log')" />
|
||||
</el-row>
|
||||
<el-row style="border-bottom:1px solid #e4e7ed;margin:0 0 20px 0;padding-bottom:20px;" />
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-row v-if="taskForm.type === 'spider'" class="task-info-spider-wrapper wrapper">
|
||||
<h4 class="title spider-title" @click="onNavigateToSpider">
|
||||
<i class="fa fa-search" style="margin-right: 5px" />
|
||||
{{ $t('Spider Info') }}</h4>
|
||||
<spider-info-view :is-view="true" />
|
||||
</el-row>
|
||||
<el-row class="task-info-node-wrapper wrapper">
|
||||
<h4 class="title node-title" @click="onNavigateToNode">
|
||||
<i class="fa fa-search" style="margin-right: 5px" />
|
||||
{{ $t('Node Info') }}</h4>
|
||||
<node-info-view :is-view="true" />
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import SpiderInfoView from '../InfoView/SpiderInfoView'
|
||||
import NodeInfoView from '../InfoView/NodeInfoView'
|
||||
import TaskInfoView from '../InfoView/TaskInfoView'
|
||||
|
||||
export default {
|
||||
name: 'SpiderOverview',
|
||||
components: {
|
||||
NodeInfoView,
|
||||
SpiderInfoView,
|
||||
TaskInfoView
|
||||
},
|
||||
computed: {
|
||||
...mapState('node', [
|
||||
'nodeForm'
|
||||
]),
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('task', [
|
||||
'taskForm'
|
||||
])
|
||||
},
|
||||
created() {
|
||||
},
|
||||
methods: {
|
||||
onNavigateToSpider() {
|
||||
this.$router.push(`/spiders/${this.spiderForm._id}`)
|
||||
this.$st.sendEv('任务详情', '概览', '点击爬虫详情')
|
||||
},
|
||||
onNavigateToNode() {
|
||||
this.$router.push(`/nodes/${this.nodeForm._id}`)
|
||||
this.$st.sendEv('任务详情', '概览', '点击节点详情')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin: 10px 0 3px 0;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spider-form {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.node-title,
|
||||
.spider-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-title:hover,
|
||||
.spider-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title > i {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-wrapper {
|
||||
text-align: right;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<div :class="{'hidden':hidden}" class="pagination-container">
|
||||
<el-pagination
|
||||
:background="background"
|
||||
:current-page.sync="currentPage"
|
||||
:page-size.sync="pageSize"
|
||||
:layout="layout"
|
||||
:page-sizes="pageSizes"
|
||||
:total="total"
|
||||
v-bind="$attrs"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { scrollTo } from '@/utils/scrollTo'
|
||||
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
props: {
|
||||
total: {
|
||||
required: true,
|
||||
type: Number
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
pageSizes: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [10, 20, 30, 50]
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper'
|
||||
},
|
||||
background: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentPage: {
|
||||
get() {
|
||||
return this.page
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:page', val)
|
||||
}
|
||||
},
|
||||
pageSize: {
|
||||
get() {
|
||||
return this.limit
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:limit', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSizeChange(val) {
|
||||
this.$emit('pagination', { page: this.currentPage, limit: val })
|
||||
if (this.autoScroll) {
|
||||
scrollTo(0, 800)
|
||||
}
|
||||
},
|
||||
handleCurrentChange(val) {
|
||||
this.$emit('pagination', { page: val, limit: this.pageSize })
|
||||
if (this.autoScroll) {
|
||||
scrollTo(0, 800)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
background: #fff;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.pagination-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div :style="{zIndex:zIndex,height:height,width:width}" class="pan-item">
|
||||
<div class="pan-info">
|
||||
<div class="pan-info-roles-container">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<img :src="image" class="pan-thumb">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PanThumb',
|
||||
props: {
|
||||
image: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '150px'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '150px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pan-item {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pan-info-roles-container {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pan-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 100%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
transform-origin: 95% 40%;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.pan-thumb:after {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
top: 40%;
|
||||
left: 95%;
|
||||
margin: -4px 0 0 -4px;
|
||||
background: radial-gradient(ellipse at center, rgba(14, 14, 14, 1) 0%, rgba(125, 126, 125, 1) 100%);
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.pan-info {
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pan-info h3 {
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
letter-spacing: 2px;
|
||||
font-size: 18px;
|
||||
margin: 0 60px;
|
||||
padding: 22px 0 0 0;
|
||||
height: 85px;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
text-shadow: 0 0 1px #fff, 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.pan-info p {
|
||||
color: #fff;
|
||||
padding: 10px 5px;
|
||||
font-style: italic;
|
||||
margin: 0 30px;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.pan-info p a {
|
||||
display: block;
|
||||
color: #333;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
padding-top: 24px;
|
||||
margin: 7px auto 0;
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease-in-out 0.2s, opacity 0.3s ease-in-out 0.2s, background 0.2s linear 0s;
|
||||
transform: translateX(60px) rotate(90deg);
|
||||
}
|
||||
|
||||
.pan-info p a:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.pan-item:hover .pan-thumb {
|
||||
transform: rotate(-110deg);
|
||||
}
|
||||
|
||||
.pan-item:hover .pan-info p a {
|
||||
opacity: 1;
|
||||
transform: translateX(0px) rotate(0deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<CreateEditDialog
|
||||
:action-functions="actionFunctions"
|
||||
:batch-form-data="formList"
|
||||
:batch-form-fields="batchFormFields"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:confirm-loading="confirmLoading"
|
||||
:tab-name="createEditDialogTabName"
|
||||
:type="activeDialogKey"
|
||||
:visible="createEditDialogVisible"
|
||||
:form-rules="formRules"
|
||||
>
|
||||
<template #default>
|
||||
<ScheduleForm/>
|
||||
</template>
|
||||
</CreateEditDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
|
||||
import ScheduleForm from '@/components/schedule/ScheduleForm.vue';
|
||||
import useSchedule from '@/components/schedule/schedule';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CreateEditScheduleDialog',
|
||||
components: {
|
||||
CreateEditDialog,
|
||||
ScheduleForm,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
...useSchedule(store),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
168
frontend/src/components/Schedule/ScheduleCron.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<Tag
|
||||
v-if="!iconOnly"
|
||||
:key="data"
|
||||
:icon="data.icon"
|
||||
:label="data.label"
|
||||
:size="size"
|
||||
:spinning="data.spinning"
|
||||
:type="data.type"
|
||||
class="schedule-cron"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<template #tooltip>
|
||||
<div v-html="data.tooltip"/>
|
||||
</template>
|
||||
</Tag>
|
||||
<div v-else :class="[isValid ? 'valid' : 'invalid']" class="schedule-cron">
|
||||
<div class="row">
|
||||
<span class="title">
|
||||
<el-tooltip content="Cron Description">
|
||||
<font-awesome-icon :icon="['fa', 'info-circle']" class="description"/>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
<span class="value description">
|
||||
{{ isValid ? description : 'Invalid' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="title">
|
||||
<el-tooltip content="Next Run">
|
||||
<font-awesome-icon :icon="['fa', 'arrow-right']" class="next"/>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
<span class="value next">
|
||||
{{ isValid ? next : 'Invalid' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import Tag from '@/components/tag/Tag.vue';
|
||||
import {CronExpression, parseExpression} from 'cron-parser';
|
||||
// import cronstrue from 'cronstrue/i18n';
|
||||
import cronstrue from 'cronstrue';
|
||||
import dayjs from 'dayjs';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
// import 'dayjs/locale/zh-cn';
|
||||
import colors from '@/styles/color.scss';
|
||||
|
||||
// TODO: internalization
|
||||
dayjs.extend(localizedFormat);
|
||||
// dayjs.locale('zh-cn');
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ScheduleCron',
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
props: {
|
||||
cron: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<BasicSize>,
|
||||
required: false,
|
||||
default: 'mini',
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props: ScheduleCronProps, {emit}) {
|
||||
const interval = computed<CronExpression | undefined>(() => {
|
||||
const {cron} = props;
|
||||
if (!cron) return;
|
||||
try {
|
||||
return parseExpression(cron);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
});
|
||||
|
||||
const next = computed<string | undefined>(() => {
|
||||
if (!interval.value) return;
|
||||
return dayjs(interval.value.next().toDate()).format('llll');
|
||||
});
|
||||
|
||||
const description = computed<string | undefined>(() => {
|
||||
const {cron} = props;
|
||||
if (!cron) return;
|
||||
// TODO: internalization
|
||||
return cronstrue.toString(cron);
|
||||
});
|
||||
|
||||
const tooltip = computed<string>(() => `<span class="title">Cron Expression: </span><span style="color: ${colors.blue}">${props.cron}</span><br>
|
||||
<span class="title">Description: </span><span style="color: ${colors.orange}">${description.value}</span><br>
|
||||
<span class="title">Next: </span><span style="color: ${colors.green}">${next.value}</span>`);
|
||||
|
||||
const isValid = computed<boolean>(() => !!interval.value);
|
||||
|
||||
const data = computed<TagData>(() => {
|
||||
const {cron} = props;
|
||||
if (!cron) {
|
||||
return {
|
||||
label: 'Unknown',
|
||||
tooltip: 'Unknown',
|
||||
type: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: cron,
|
||||
tooltip: tooltip.value,
|
||||
type: 'primary',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
next,
|
||||
description,
|
||||
isValid,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/variables.scss";
|
||||
|
||||
.schedule-cron {
|
||||
.row {
|
||||
min-height: 20px;
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $warningColor;
|
||||
}
|
||||
|
||||
.next {
|
||||
color: $successColor;
|
||||
}
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
.description,
|
||||
.next {
|
||||
color: $infoMediumColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
193
frontend/src/components/Schedule/ScheduleForm.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Form
|
||||
v-if="form"
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
:selective="isSelectiveForm"
|
||||
class="schedule-form"
|
||||
>
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Name" prop="name" required>
|
||||
<el-input v-model="form.name" :disabled="isFormItemDisabled('name')" placeholder="Name"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Spider" prop="spider_id" required>
|
||||
<el-select
|
||||
v-model="form.spider_id"
|
||||
:disabled="isFormItemDisabled('spider_id')"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in allSpiderSelectOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Cron Expression" prop="cron" required>
|
||||
<el-input v-model="form.cron" :disabled="isFormItemDisabled('cron')" placeholder="Cron Expression"/>
|
||||
</FormItem>
|
||||
<FormItem :not-editable="isSelectiveForm" :span="2" label="Cron Info">
|
||||
<div class="nav-btn">
|
||||
<ScheduleCron :cron="form.cron" icon-only size="small"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Command" prop="cmd">
|
||||
<InputWithButton
|
||||
v-model="form.cmd"
|
||||
:button-icon="['fa', 'edit']"
|
||||
:disabled="isFormItemDisabled('cmd')"
|
||||
button-label="Edit"
|
||||
placeholder="Command"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Param" prop="param">
|
||||
<InputWithButton
|
||||
v-model="form.param"
|
||||
:button-icon="['fa', 'edit']"
|
||||
:disabled="isFormItemDisabled('param')"
|
||||
button-label="Edit"
|
||||
placeholder="Params"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Default Mode" prop="mode">
|
||||
<el-select
|
||||
v-model="form.mode"
|
||||
:disabled="isFormItemDisabled('mode')"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in modeOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Enabled" prop="enabled" required>
|
||||
<Switch v-model="form.enabled" @change="onEnabledChange"/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<FormItem
|
||||
v-if="form.mode === TASK_MODE_SELECTED_NODE_TAGS"
|
||||
:span="4"
|
||||
label="Selected Tags"
|
||||
prop="node_tags"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="form.node_tags"
|
||||
:disabled="isFormItemDisabled('node_tags')"
|
||||
:options="allNodeTags"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
v-if="[TASK_MODE_SELECTED_NODES, TASK_MODE_SELECTED_NODE_TAGS].includes(form.mode)"
|
||||
:span="4"
|
||||
label="Selected Nodes"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="form.node_ids"
|
||||
:disabled="form.mode === TASK_MODE_SELECTED_NODE_TAGS && isFormItemDisabled('node_ids')"
|
||||
:options="allNodeSelectOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="4" label="Description" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
:disabled="isFormItemDisabled('description')"
|
||||
placeholder="Description"
|
||||
type="textarea"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import useSchedule from '@/components/schedule/schedule';
|
||||
import Form from '@/components/form/Form.vue';
|
||||
import FormItem from '@/components/form/FormItem.vue';
|
||||
import useSpider from '@/components/spider/spider';
|
||||
import {TASK_MODE_SELECTED_NODE_TAGS, TASK_MODE_SELECTED_NODES} from '@/constants/task';
|
||||
import useNode from '@/components/node/node';
|
||||
import CheckTagGroup from '@/components/tag/CheckTagGroup.vue';
|
||||
import InputWithButton from '@/components/input/InputWithButton.vue';
|
||||
import Switch from '@/components/switch/Switch.vue';
|
||||
import {ElMessage} from 'element-plus';
|
||||
import ScheduleCron from '@/components/schedule/ScheduleCron.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ScheduleForm',
|
||||
components: {
|
||||
ScheduleCron,
|
||||
Switch,
|
||||
FormItem,
|
||||
Form,
|
||||
CheckTagGroup,
|
||||
InputWithButton,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const ns = 'schedule';
|
||||
const store = useStore();
|
||||
|
||||
// use node
|
||||
const {
|
||||
allListSelectOptions: allNodeSelectOptions,
|
||||
allTags: allNodeTags,
|
||||
} = useNode(store);
|
||||
|
||||
// use spider
|
||||
const {
|
||||
allListSelectOptions: allSpiderSelectOptions,
|
||||
} = useSpider(store);
|
||||
|
||||
// use schedule
|
||||
const {
|
||||
form,
|
||||
} = useSchedule(store);
|
||||
|
||||
// on enabled change
|
||||
const onEnabledChange = async (value: boolean) => {
|
||||
if (value) {
|
||||
await store.dispatch(`${ns}/enable`, form.value._id);
|
||||
ElMessage.success('Enabled successfully');
|
||||
} else {
|
||||
await store.dispatch(`${ns}/disable`, form.value._id);
|
||||
ElMessage.success('Disabled successfully');
|
||||
}
|
||||
await store.dispatch(`${ns}/getList`);
|
||||
};
|
||||
|
||||
return {
|
||||
...useSchedule(store),
|
||||
|
||||
allSpiderSelectOptions,
|
||||
allNodeSelectOptions,
|
||||
allNodeTags,
|
||||
TASK_MODE_SELECTED_NODES,
|
||||
TASK_MODE_SELECTED_NODE_TAGS,
|
||||
onEnabledChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import TaskList from '../../views/task/TaskList'
|
||||
|
||||
export default {
|
||||
name: 'ScheduleTaskList',
|
||||
extends: TaskList,
|
||||
computed: {
|
||||
...mapState('task', [
|
||||
'filter'
|
||||
]),
|
||||
...mapState('schedule', [
|
||||
'scheduleForm'
|
||||
])
|
||||
},
|
||||
async created() {
|
||||
this.update()
|
||||
},
|
||||
methods: {
|
||||
update() {
|
||||
this.isFilterSpiderDisabled = true
|
||||
this.$set(this.filter, 'spider_id', this.scheduleForm.spider_id)
|
||||
this.filter.schedule_id = this.scheduleForm._id
|
||||
this.$store.dispatch('task/getTaskList')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
153
frontend/src/components/Schedule/schedule.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {computed, readonly, watch} from 'vue';
|
||||
import {Store} from 'vuex';
|
||||
import useForm from '@/components/form/form';
|
||||
import useScheduleService from '@/services/schedule/scheduleService';
|
||||
import {getDefaultFormComponentData} from '@/utils/form';
|
||||
import {
|
||||
FORM_FIELD_TYPE_INPUT,
|
||||
FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
FORM_FIELD_TYPE_SELECT,
|
||||
FORM_FIELD_TYPE_SWITCH,
|
||||
} from '@/constants/form';
|
||||
import {parseExpression} from 'cron-parser';
|
||||
import {getModeOptions} from '@/utils/task';
|
||||
import useSpider from '@/components/spider/spider';
|
||||
import {TASK_MODE_RANDOM} from '@/constants/task';
|
||||
|
||||
// get new schedule
|
||||
export const getNewSchedule = (): Schedule => {
|
||||
return {
|
||||
enabled: true,
|
||||
mode: TASK_MODE_RANDOM,
|
||||
};
|
||||
};
|
||||
|
||||
// form component data
|
||||
const formComponentData = getDefaultFormComponentData<Schedule>(getNewSchedule);
|
||||
|
||||
const useSchedule = (store: Store<RootStoreState>) => {
|
||||
// store
|
||||
const ns = 'schedule';
|
||||
const state = store.state[ns];
|
||||
|
||||
const {
|
||||
allListSelectOptions: allSpiderListSelectOptions,
|
||||
allDict: allSpiderDict,
|
||||
} = useSpider(store);
|
||||
// form
|
||||
const form = computed<Schedule>(() => state.form);
|
||||
|
||||
// options for default mode
|
||||
const modeOptions = getModeOptions();
|
||||
|
||||
// readonly form fields
|
||||
const readonlyFormFields = computed<string[]>(() => state.readonlyFormFields);
|
||||
|
||||
// batch form fields
|
||||
const batchFormFields = computed<FormTableField[]>(() => [
|
||||
{
|
||||
prop: 'name',
|
||||
label: 'Name',
|
||||
width: '150',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT,
|
||||
placeholder: 'Name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'spider_id',
|
||||
label: 'Spider',
|
||||
width: '150',
|
||||
placeholder: 'Spider',
|
||||
fieldType: FORM_FIELD_TYPE_SELECT,
|
||||
options: allSpiderListSelectOptions.value,
|
||||
disabled: () => readonlyFormFields.value.includes('spider_id'),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'cron',
|
||||
label: 'Cron Expression',
|
||||
width: '150',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT,
|
||||
placeholder: 'Name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'cmd',
|
||||
label: 'Execute Command',
|
||||
width: '200',
|
||||
placeholder: 'Execute Command',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
},
|
||||
{
|
||||
prop: 'param',
|
||||
label: 'Param',
|
||||
width: '200',
|
||||
placeholder: 'Param',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
},
|
||||
{
|
||||
prop: 'mode',
|
||||
label: 'Default Run Mode',
|
||||
width: '200',
|
||||
fieldType: FORM_FIELD_TYPE_SELECT,
|
||||
options: modeOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'enabled',
|
||||
label: 'Enabled',
|
||||
width: '80',
|
||||
fieldType: FORM_FIELD_TYPE_SWITCH,
|
||||
required: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// form rules
|
||||
const formRules = readonly<FormRules>({
|
||||
cron: {
|
||||
trigger: 'blur',
|
||||
validator: ((_, value: string, callback) => {
|
||||
const invalidMessage = 'Invalid cron expression. [min] [hour] [day of month] [month] [day of week]';
|
||||
if (!value) return callback(invalidMessage);
|
||||
if (value.trim().split(' ').length != 5) return callback(invalidMessage);
|
||||
try {
|
||||
parseExpression(value);
|
||||
callback();
|
||||
} catch (e) {
|
||||
callback(e.message);
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// all schedule select options
|
||||
const allScheduleSelectOptions = computed<SelectOption[]>(() => state.allList.map(d => {
|
||||
return {
|
||||
label: d.name,
|
||||
value: d._id,
|
||||
};
|
||||
}));
|
||||
|
||||
watch(() => form.value?.spider_id, () => {
|
||||
if (!form.value?.spider_id) return;
|
||||
const spider = allSpiderDict.value.get(form.value?.spider_id);
|
||||
if (!spider) return;
|
||||
const payload = {...form.value} as Schedule;
|
||||
if (spider.cmd) payload.cmd = spider.cmd;
|
||||
if (spider.param) payload.param = spider.param;
|
||||
if (spider.mode) payload.mode = spider.mode;
|
||||
if (spider.node_ids?.length) payload.node_ids = spider.node_ids;
|
||||
if (spider.node_tags?.length) payload.node_tags = spider.node_tags;
|
||||
store.commit(`${ns}/setForm`, payload);
|
||||
});
|
||||
|
||||
return {
|
||||
...useForm('schedule', store, useScheduleService(store), formComponentData),
|
||||
modeOptions,
|
||||
batchFormFields,
|
||||
formRules,
|
||||
allScheduleSelectOptions,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSchedule;
|
||||
@@ -1,846 +0,0 @@
|
||||
<template>
|
||||
<div class="spider-scrapy">
|
||||
<!--parameter edit-->
|
||||
<el-dialog
|
||||
:title="$t('Parameter Edit')"
|
||||
:visible="dialogVisible"
|
||||
class="setting-param-dialog"
|
||||
width="600px"
|
||||
:before-close="onCloseDialog"
|
||||
>
|
||||
<div class="action-wrapper" style="margin-bottom: 10px;text-align: right">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="el-icon-plus"
|
||||
@click="onSettingsActiveParamAdd"
|
||||
>
|
||||
{{$t('Add')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="activeParamData"
|
||||
>
|
||||
<el-table-column
|
||||
v-if="activeParam.type === 'object'"
|
||||
:label="$t('Key')"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.key" size="small"/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Value')"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="activeParam.type === 'object'"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
type="number"
|
||||
@change="() => scope.row.value = Number(scope.row.value)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="activeParam.type === 'array'"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
width="60px"
|
||||
align="center"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="mini"
|
||||
icon="el-icon-delete"
|
||||
circle
|
||||
@click="onSettingsActiveParamRemove(scope.$index)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="onCloseDialog">{{$t('Cancel')}}</el-button>
|
||||
<el-button type="primary" size="small" @click="onSettingsConfirm">
|
||||
{{$t('Confirm')}}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!--./parameter edit-->
|
||||
|
||||
<!--add scrapy spider-->
|
||||
<el-dialog
|
||||
:title="$t('Add Scrapy Spider')"
|
||||
:visible.sync="isAddSpiderVisible"
|
||||
width="480px"
|
||||
>
|
||||
<el-form
|
||||
:model="addSpiderForm"
|
||||
label-width="80px"
|
||||
ref="add-spider-form"
|
||||
inline-message
|
||||
>
|
||||
<el-form-item :label="$t('Name')" prop="name" required>
|
||||
<el-input v-model="addSpiderForm.name" :placeholder="$t('Name')"/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Domain')" prop="domain" required>
|
||||
<el-input v-model="addSpiderForm.domain" :placeholder="$t('Domain')"/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('Template')" prop="template" required>
|
||||
<el-select v-model="addSpiderForm.template" :placeholder="$t('Template')">
|
||||
<el-option value="basic" label="basic"/>
|
||||
<el-option value="crawl" label="crawl"/>
|
||||
<el-option value="csvfeed" label="csvfeed"/>
|
||||
<el-option value="xmlfeed" label="xmlfeed"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="isAddSpiderVisible = false">{{$t('Cancel')}}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="onAddSpiderConfirm"
|
||||
:icon="isAddSpiderLoading ? 'el-icon-loading' : ''"
|
||||
:disabled="isAddSpiderLoading"
|
||||
>
|
||||
{{$t('Confirm')}}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!--./add scrapy spider-->
|
||||
|
||||
<el-tabs
|
||||
v-model="activeTabName"
|
||||
>
|
||||
<!--settings-->
|
||||
<el-tab-pane :label="$t('Settings')" name="settings">
|
||||
<div class="settings">
|
||||
<div v-if="!spiderScrapySettings || !spiderScrapySettings.length" class="settings">
|
||||
<span class="empty-text">
|
||||
{{$t('No data available')}}
|
||||
</span>
|
||||
<template v-if="spiderScrapyErrors.settings">
|
||||
<label class="errors-label">{{$t('Errors')}}:</label>
|
||||
<el-alert type="error" v-html="getScrapyErrors('settings')"/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="settings">
|
||||
<div class="top-action-wrapper">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="el-icon-plus"
|
||||
@click="onSettingsAdd"
|
||||
>
|
||||
{{$t('Add Variable')}}
|
||||
</el-button>
|
||||
<el-button size="small" type="success" @click="onSettingsSave" icon="el-icon-check">
|
||||
{{$t('Save')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="spiderScrapySettings"
|
||||
border
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
max-height="calc(100vh - 240px)"
|
||||
>
|
||||
<el-table-column
|
||||
:label="$t('Variable Name')"
|
||||
width="240px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-autocomplete
|
||||
v-model="scope.row.key"
|
||||
size="small"
|
||||
suffix-icon="el-icon-edit"
|
||||
:fetch-suggestions="settingsKeysFetchSuggestions"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Variable Type')"
|
||||
width="120px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-select v-model="scope.row.type" size="small" @change="onSettingsParamTypeChange(scope.row)">
|
||||
<el-option value="string" :label="$t('String')"/>
|
||||
<el-option value="number" :label="$t('Number')"/>
|
||||
<el-option value="boolean" :label="$t('Boolean')"/>
|
||||
<el-option value="array" :label="$t('Array/List')"/>
|
||||
<el-option value="object" :label="$t('Object/Dict')"/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Variable Value')"
|
||||
width="calc(100% - 150px)"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="scope.row.type === 'string'"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
suffix-icon="el-icon-edit"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="scope.row.type === 'number'"
|
||||
type="number"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
suffix-icon="el-icon-edit"
|
||||
@change="scope.row.value = Number(scope.row.value)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="scope.row.type === 'boolean'"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
<el-switch
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
active-color="#67C23A"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
style="margin-left: 10px;font-size: 12px"
|
||||
>
|
||||
{{JSON.stringify(scope.row.value)}}
|
||||
<el-button
|
||||
type="warning"
|
||||
size="mini"
|
||||
icon="el-icon-edit"
|
||||
style="margin-left: 10px"
|
||||
@click="onSettingsEditParam(scope.row, scope.$index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Action')"
|
||||
width="60px"
|
||||
align="center"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="mini"
|
||||
icon="el-icon-delete"
|
||||
circle
|
||||
@click="onSettingsRemove(scope.$index)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<!--./settings-->
|
||||
|
||||
<!--spiders-->
|
||||
<el-tab-pane :label="$t('Spiders')" name="spiders">
|
||||
<div class="spiders">
|
||||
<div v-if="!spiderForm.spider_names || !spiderForm.spider_names.length" class="spiders">
|
||||
<span class="empty-text error">
|
||||
{{$t('No data available. Please check whether your spiders are missing dependencies or no spiders created.')}}
|
||||
</span>
|
||||
<template v-if="spiderScrapyErrors.spiders">
|
||||
<label class="errors-label">{{$t('Errors')}}:</label>
|
||||
<el-alert type="error" v-html="getScrapyErrors('spiders')"/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="spiders">
|
||||
<div class="action-wrapper">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="el-icon-plus"
|
||||
@click="onAddSpider"
|
||||
>
|
||||
{{$t('Add Spider')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="s in spiderForm.spider_names"
|
||||
:key="s"
|
||||
class="item"
|
||||
@click="onClickSpider(s)"
|
||||
>
|
||||
<i class="el-icon-star-on"></i>
|
||||
{{s}}
|
||||
<i v-if="loadingDict[s]" class="el-icon-loading"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<!--./spiders-->
|
||||
|
||||
<!--items-->
|
||||
<el-tab-pane label="Items" name="items">
|
||||
<div class="items">
|
||||
<div v-if="!spiderScrapyItems || !spiderScrapyItems.length" class="items">
|
||||
<span class="empty-text">
|
||||
{{$t('No data available')}}
|
||||
</span>
|
||||
<template v-if="spiderScrapyErrors.items">
|
||||
<label class="errors-label">{{$t('Errors')}}:</label>
|
||||
<el-alert type="error" v-html="getScrapyErrors('items')"/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="items">
|
||||
<div class="action-wrapper">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="el-icon-plus"
|
||||
@click="onAddItem"
|
||||
>
|
||||
{{$t('Add Item')}}
|
||||
</el-button>
|
||||
<el-button size="small" type="success" @click="onItemsSave" icon="el-icon-check">
|
||||
{{$t('Save')}}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tree
|
||||
:data="spiderScrapyItems"
|
||||
default-expand-all
|
||||
>
|
||||
<span class="custom-tree-node" :class="`level-${data.level}`" slot-scope="{ node, data }">
|
||||
<template v-if="data.level === 1">
|
||||
<span v-if="!node.isEdit" class="label" @click="onItemLabelEdit(node, data, $event)">
|
||||
<i class="el-icon-star-on"></i>
|
||||
{{ data.label }}
|
||||
<i class="el-icon-edit"></i>
|
||||
</span>
|
||||
<el-input
|
||||
v-else
|
||||
:ref="`el-input-${data.id}`"
|
||||
:placeholder="$t('Item Name')"
|
||||
v-model="data.name"
|
||||
size="mini"
|
||||
@change="onItemChange(node, data, $event)"
|
||||
@blur="$set(node, 'isEdit', false)"
|
||||
/>
|
||||
<span>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
icon="el-icon-plus"
|
||||
@click="onAddItemField(data, $event)">
|
||||
{{$t('Add Field')}}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="mini"
|
||||
icon="el-icon-delete"
|
||||
@click="removeItem(data, $event)">
|
||||
{{$t('Remove')}}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="data.level === 2">
|
||||
<span v-if="!node.isEdit" class="label" @click="onItemLabelEdit(node, data, $event)">
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
{{ node.label }}
|
||||
<i class="el-icon-edit"></i>
|
||||
</span>
|
||||
<el-input
|
||||
v-else
|
||||
:ref="`el-input-${data.id}`"
|
||||
:placeholder="$t('Field Name')"
|
||||
v-model="data.name"
|
||||
size="mini"
|
||||
@change="onItemFieldChange(node, data, $event)"
|
||||
@blur="node.isEdit = false"
|
||||
/>
|
||||
<span>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="mini"
|
||||
icon="el-icon-delete"
|
||||
@click="onRemoveItemField(node, data, $event)">
|
||||
{{$t('Remove')}}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<!--./items-->
|
||||
|
||||
<!--pipelines-->
|
||||
<el-tab-pane label="Pipelines" name="pipelines">
|
||||
<div v-if="!spiderScrapyPipelines || !spiderScrapyPipelines.length" class="pipelines">
|
||||
<span class="empty-text">
|
||||
{{$t('No data available')}}
|
||||
</span>
|
||||
<template v-if="spiderScrapyErrors.pipelines">
|
||||
<label class="errors-label">{{$t('Errors')}}:</label>
|
||||
<el-alert type="error" v-html="getScrapyErrors('pipelines')"/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="pipelines">
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="s in spiderScrapyPipelines"
|
||||
:key="s"
|
||||
class="item"
|
||||
@click="$emit('click-pipeline')"
|
||||
>
|
||||
<i class="el-icon-star-on"></i>
|
||||
{{s}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<!--./pipelines-->
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'SpiderScrapy',
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm',
|
||||
'spiderScrapySettings',
|
||||
'spiderScrapyItems',
|
||||
'spiderScrapyPipelines',
|
||||
'spiderScrapyErrors'
|
||||
]),
|
||||
activeParamData() {
|
||||
if (this.activeParam.type === 'array') {
|
||||
return this.activeParam.value.map(s => {
|
||||
return { value: s }
|
||||
})
|
||||
} else if (this.activeParam.type === 'object') {
|
||||
return Object.keys(this.activeParam.value).map(key => {
|
||||
return {
|
||||
key,
|
||||
value: this.activeParam.value[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: false,
|
||||
activeParam: {},
|
||||
activeParamIndex: undefined,
|
||||
isAddSpiderVisible: false,
|
||||
addSpiderForm: {
|
||||
name: '',
|
||||
domain: '',
|
||||
template: 'basic'
|
||||
},
|
||||
isAddSpiderLoading: false,
|
||||
activeTabName: 'settings',
|
||||
loadingDict: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onOpenDialog() {
|
||||
this.dialogVisible = true
|
||||
},
|
||||
onCloseDialog() {
|
||||
this.dialogVisible = false
|
||||
},
|
||||
onSettingsConfirm() {
|
||||
if (this.activeParam.type === 'array') {
|
||||
this.activeParam.value = this.activeParamData.map(d => d.value)
|
||||
} else if (this.activeParam.type === 'object') {
|
||||
const dict = {}
|
||||
this.activeParamData.forEach(d => {
|
||||
dict[d.key] = d.value
|
||||
})
|
||||
this.activeParam.value = dict
|
||||
}
|
||||
this.$set(this.spiderScrapySettings, this.activeParamIndex, JSON.parse(JSON.stringify(this.activeParam)))
|
||||
this.dialogVisible = false
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '确认编辑参数')
|
||||
},
|
||||
onSettingsEditParam(row, index) {
|
||||
this.activeParam = JSON.parse(JSON.stringify(row))
|
||||
this.activeParamIndex = index
|
||||
this.onOpenDialog()
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '点击编辑参数')
|
||||
},
|
||||
async onSettingsSave() {
|
||||
const res = await this.$store.dispatch('spider/saveSpiderScrapySettings', this.$route.params.id)
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Saved successfully'))
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '保存设置')
|
||||
},
|
||||
onSettingsAdd() {
|
||||
const data = JSON.parse(JSON.stringify(this.spiderScrapySettings))
|
||||
data.push({
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string'
|
||||
})
|
||||
this.$store.commit('spider/SET_SPIDER_SCRAPY_SETTINGS', data)
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '添加参数')
|
||||
},
|
||||
onSettingsRemove(index) {
|
||||
const data = JSON.parse(JSON.stringify(this.spiderScrapySettings))
|
||||
data.splice(index, 1)
|
||||
this.$store.commit('spider/SET_SPIDER_SCRAPY_SETTINGS', data)
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '删除参数')
|
||||
},
|
||||
onSettingsActiveParamAdd() {
|
||||
if (this.activeParam.type === 'array') {
|
||||
this.activeParam.value.push('')
|
||||
} else if (this.activeParam.type === 'object') {
|
||||
if (!this.activeParam.value) {
|
||||
this.activeParam.value = {}
|
||||
}
|
||||
this.$set(this.activeParam.value, '', 999)
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '添加参数中参数')
|
||||
},
|
||||
onSettingsActiveParamRemove(index) {
|
||||
if (this.activeParam.type === 'array') {
|
||||
this.activeParam.value.splice(index, 1)
|
||||
} else if (this.activeParam.type === 'object') {
|
||||
const key = this.activeParamData[index].key
|
||||
const value = JSON.parse(JSON.stringify(this.activeParam.value))
|
||||
delete value[key]
|
||||
this.$set(this.activeParam, 'value', value)
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '删除参数中参数')
|
||||
},
|
||||
settingsKeysFetchSuggestions(queryString, cb) {
|
||||
const data = this.$utils.scrapy.settingParamNames
|
||||
.filter(s => {
|
||||
if (!queryString) return true
|
||||
return !!s.match(new RegExp(queryString, 'i'))
|
||||
})
|
||||
.map(s => {
|
||||
return {
|
||||
value: s,
|
||||
label: s
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a > b ? -1 : 1
|
||||
})
|
||||
cb(data)
|
||||
},
|
||||
onSettingsParamTypeChange(row) {
|
||||
if (row.type === 'number') {
|
||||
row.value = Number(row.value)
|
||||
}
|
||||
},
|
||||
onAddSpiderConfirm() {
|
||||
this.$refs['add-spider-form'].validate(async valid => {
|
||||
if (!valid) return
|
||||
this.isAddSpiderLoading = true
|
||||
const res = await this.$store.dispatch('spider/addSpiderScrapySpider', {
|
||||
id: this.$route.params.id,
|
||||
form: this.addSpiderForm
|
||||
})
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Saved successfully'))
|
||||
}
|
||||
this.isAddSpiderVisible = false
|
||||
this.isAddSpiderLoading = false
|
||||
await this.$store.dispatch('spider/getSpiderScrapySpiders', this.$route.params.id)
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '确认添加爬虫')
|
||||
},
|
||||
onAddSpider() {
|
||||
this.addSpiderForm = {
|
||||
name: '',
|
||||
domain: '',
|
||||
template: 'basic'
|
||||
}
|
||||
this.isAddSpiderVisible = true
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '添加爬虫')
|
||||
},
|
||||
getMaxItemNodeId() {
|
||||
let max = 0
|
||||
this.spiderScrapyItems.forEach(d => {
|
||||
if (max < d.id) max = d.id
|
||||
d.children.forEach(f => {
|
||||
if (max < f.id) max = f.id
|
||||
})
|
||||
})
|
||||
return max
|
||||
},
|
||||
onAddItem() {
|
||||
const maxId = this.getMaxItemNodeId()
|
||||
this.spiderScrapyItems.push({
|
||||
id: maxId + 1,
|
||||
label: `Item_${+new Date()}`,
|
||||
level: 1,
|
||||
children: [
|
||||
{
|
||||
id: maxId + 2,
|
||||
level: 2,
|
||||
label: `field_${+new Date()}`
|
||||
}
|
||||
]
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '添加Item')
|
||||
},
|
||||
removeItem(data, ev) {
|
||||
ev.stopPropagation()
|
||||
for (let i = 0; i < this.spiderScrapyItems.length; i++) {
|
||||
const item = this.spiderScrapyItems[i]
|
||||
if (item.id === data.id) {
|
||||
this.spiderScrapyItems.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '删除Item')
|
||||
},
|
||||
onAddItemField(data, ev) {
|
||||
ev.stopPropagation()
|
||||
for (let i = 0; i < this.spiderScrapyItems.length; i++) {
|
||||
const item = this.spiderScrapyItems[i]
|
||||
if (item.id === data.id) {
|
||||
item.children.push({
|
||||
id: this.getMaxItemNodeId() + 1,
|
||||
level: 2,
|
||||
label: `field_${+new Date()}`
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '添加Items字段')
|
||||
},
|
||||
onRemoveItemField(node, data, ev) {
|
||||
ev.stopPropagation()
|
||||
for (let i = 0; i < this.spiderScrapyItems.length; i++) {
|
||||
const item = this.spiderScrapyItems[i]
|
||||
if (item.id === node.parent.data.id) {
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const field = item.children[j]
|
||||
if (field.id === data.id) {
|
||||
item.children.splice(j, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '删除Items字段')
|
||||
},
|
||||
onItemLabelEdit(node, data, ev) {
|
||||
ev.stopPropagation()
|
||||
this.$set(node, 'isEdit', true)
|
||||
this.$set(data, 'name', node.label)
|
||||
setTimeout(() => {
|
||||
this.$refs[`el-input-${data.id}`].focus()
|
||||
}, 0)
|
||||
},
|
||||
onItemChange(node, data, value) {
|
||||
for (let i = 0; i < this.spiderScrapyItems.length; i++) {
|
||||
const item = this.spiderScrapyItems[i]
|
||||
if (item.id === data.id) {
|
||||
item.label = value
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
onItemFieldChange(node, data, value) {
|
||||
for (let i = 0; i < this.spiderScrapyItems.length; i++) {
|
||||
const item = this.spiderScrapyItems[i]
|
||||
if (item.id === node.parent.data.id) {
|
||||
for (let j = 0; j < item.children.length; j++) {
|
||||
const field = item.children[j]
|
||||
if (field.id === data.id) {
|
||||
item.children[j].label = value
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
async onItemsSave() {
|
||||
const res = await this.$store.dispatch('spider/saveSpiderScrapyItems', this.$route.params.id)
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Saved successfully'))
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '保存Items')
|
||||
},
|
||||
async onClickSpider(spiderName) {
|
||||
if (this.loadingDict[spiderName]) return
|
||||
this.$set(this.loadingDict, spiderName, true)
|
||||
try {
|
||||
const res = await this.$store.dispatch('spider/getSpiderScrapySpiderFilepath', {
|
||||
id: this.$route.params.id,
|
||||
spiderName
|
||||
})
|
||||
this.$emit('click-spider', res.data.data)
|
||||
} finally {
|
||||
this.$set(this.loadingDict, spiderName, false)
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Scrapy 设置', '点击爬虫')
|
||||
},
|
||||
getScrapyErrors(type) {
|
||||
if (!this.spiderScrapyErrors || !this.spiderScrapyErrors[type] || (typeof this.spiderScrapyErrors[type] !== 'string')) return ''
|
||||
return this.$utils.html.htmlEscape(this.spiderScrapyErrors[type]).split('\n').join('<br/>')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spider-scrapy {
|
||||
height: calc(100vh - 200px);
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.spider-scrapy >>> .el-tabs__content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.spider-scrapy >>> .el-tab-pane {
|
||||
height: calc(100vh - 239px);
|
||||
}
|
||||
|
||||
.settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings .title {
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings >>> .el-table td,
|
||||
.settings >>> .el-table td .cell {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings >>> .el-table td .cell .el-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings >>> .el-table td .cell .el-input__inner {
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings >>> .action-wrapper {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings >>> .top-action-wrapper {
|
||||
margin-bottom: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings >>> .top-action-wrapper .el-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.spiders {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.spiders .action-wrapper {
|
||||
text-align: right;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
}
|
||||
|
||||
.pipelines .list,
|
||||
.spiders .list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pipelines .list .item,
|
||||
.spiders .list .item {
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipelines .list .item:hover,
|
||||
.spiders .list .item:hover {
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.items {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.items >>> .action-wrapper {
|
||||
text-align: right;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
}
|
||||
|
||||
.items >>> .custom-tree-node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
padding-right: 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.items >>> .el-tree > .el-tree-node {
|
||||
border-bottom: 1px solid #e6e9f0;
|
||||
}
|
||||
|
||||
.items >>> .el-tree-node__content {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.items >>> .custom-tree-node .label i.el-icon-edit {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.items >>> .custom-tree-node:hover .label i.el-icon-edit {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.items >>> .custom-tree-node .el-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-text.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.errors-label {
|
||||
color: #f56c6c;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg-icon :icon-class="isFullscreen?'exit-fullscreen':'fullscreen'" @click="click" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import screenfull from 'screenfull'
|
||||
|
||||
export default {
|
||||
name: 'Screenfull',
|
||||
data() {
|
||||
return {
|
||||
isFullscreen: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
if (!screenfull.enabled) {
|
||||
this.$message({
|
||||
message: 'you browser can not work',
|
||||
type: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
screenfull.toggle()
|
||||
},
|
||||
init() {
|
||||
if (screenfull.enabled) {
|
||||
screenfull.on('change', () => {
|
||||
this.isFullscreen = screenfull.isFullscreen
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screenfull-svg {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
fill: #5a5e66;;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const tagAndTagSpacing = 4 // tagAndTagSpacing
|
||||
|
||||
export default {
|
||||
name: 'ScrollPane',
|
||||
data() {
|
||||
return {
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleScroll(e) {
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const $scrollWrapper = this.$refs.scrollContainer.$refs.wrap
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
|
||||
},
|
||||
moveToTarget(currentTag) {
|
||||
const $container = this.$refs.scrollContainer.$el
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $scrollWrapper = this.$refs.scrollContainer.$refs.wrap
|
||||
const tagList = this.$parent.$refs.tag
|
||||
|
||||
let firstTag = null
|
||||
let lastTag = null
|
||||
|
||||
// find first tag and last tag
|
||||
if (tagList.length > 0) {
|
||||
firstTag = tagList[0]
|
||||
lastTag = tagList[tagList.length - 1]
|
||||
}
|
||||
|
||||
if (firstTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = 0
|
||||
} else if (lastTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
|
||||
} else {
|
||||
// find preTag and nextTag
|
||||
const currentIndex = tagList.findIndex(item => item === currentTag)
|
||||
const prevTag = tagList[currentIndex - 1]
|
||||
const nextTag = tagList[currentIndex + 1]
|
||||
// the tag's offsetLeft after of nextTag
|
||||
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
|
||||
|
||||
// the tag's offsetLeft before of prevTag
|
||||
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
|
||||
|
||||
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
|
||||
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
|
||||
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
|
||||
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss" scoped>
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
/deep/ {
|
||||
.el-scrollbar__bar {
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.el-scrollbar__wrap {
|
||||
height: 49px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,121 +0,0 @@
|
||||
<template>
|
||||
<div class="log-item" :style="style" :class="`log-item-${source.index} ${source.active ? 'active' : ''}`">
|
||||
<div class="line-no">{{ source.index }}</div>
|
||||
<div class="line-content">
|
||||
<span v-if="isLogEnd" style="color: #E6A23C">
|
||||
<span class="loading-text">{{ $t('Updating log...') }}</span>
|
||||
<i class="el-icon-loading" />
|
||||
</span>
|
||||
<span v-else-if="source.isAnsi" v-html="dataHtml" />
|
||||
<span v-else v-html="dataHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapGetters
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'LogItem',
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', [
|
||||
'userInfo'
|
||||
]),
|
||||
errorRegex() {
|
||||
if (!this.userInfo.setting.error_regex_pattern) {
|
||||
return this.$utils.log.errorRegex
|
||||
}
|
||||
console.log(this.userInfo.setting.error_regex_pattern)
|
||||
return new RegExp(this.userInfo.setting.error_regex_pattern, 'i')
|
||||
},
|
||||
dataHtml() {
|
||||
let html = this.source.data.replace(this.errorRegex, ' <span style="font-weight: bolder; text-decoration: underline">$1</span> ')
|
||||
if (!this.source.searchString) return html
|
||||
html = html.replace(new RegExp(`(${this.source.searchString})`, 'gi'), '<mark>$1</mark>')
|
||||
return html
|
||||
},
|
||||
style() {
|
||||
let color = ''
|
||||
if (this.source.data.match(this.errorRegex)) {
|
||||
color = '#F56C6C'
|
||||
}
|
||||
return {
|
||||
color
|
||||
}
|
||||
},
|
||||
isLogEnd() {
|
||||
return this.source.data === '###LOG_END###'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: rgba(55, 57, 59, 0.5);
|
||||
}
|
||||
|
||||
.log-item:first-child .line-no {
|
||||
padding-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.log-item .line-no {
|
||||
display: inline-block;
|
||||
width: 70px;
|
||||
color: #A9B7C6;
|
||||
background: #313335;
|
||||
padding-right: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.log-item.active .line-no {
|
||||
background: #E6A23C;
|
||||
color: white;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.log-item .line-content {
|
||||
padding-left: 10px;
|
||||
display: inline-block;
|
||||
width: calc(100% - 70px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-right: 5px;
|
||||
animation: blink 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,377 +0,0 @@
|
||||
<template>
|
||||
<div class="log-view-container">
|
||||
<div class="filter-wrapper">
|
||||
<div class="left">
|
||||
<el-switch
|
||||
v-model="isLogAutoScroll"
|
||||
:inactive-text="$t('Auto-Scroll')"
|
||||
style="margin-right: 10px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="logKeyword"
|
||||
size="small"
|
||||
suffix-icon="el-icon-search"
|
||||
:placeholder="$t('Search Log')"
|
||||
style="width: 240px; margin-right: 10px"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon="el-icon-search"
|
||||
@click="onSearchLog"
|
||||
>
|
||||
{{ $t('Search Log') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="right">
|
||||
<el-pagination
|
||||
size="small"
|
||||
:total="taskLogTotal"
|
||||
:current-page.sync="taskLogPage"
|
||||
:page-sizes="[1000, 2000, 5000, 10000]"
|
||||
:page-size.sync="taskLogPageSize"
|
||||
:page-count="3"
|
||||
layout="sizes, prev, pager, next"
|
||||
/>
|
||||
<el-badge
|
||||
v-if="errorLogData.length > 0"
|
||||
:value="errorLogData.length"
|
||||
>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon="el-icon-warning-outline"
|
||||
@click="toggleErrors"
|
||||
>
|
||||
{{ $t('Error Count') }}
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div
|
||||
:loading="isLogFetchLoading"
|
||||
class="log-view-wrapper"
|
||||
:class="isErrorsCollapsed ? 'errors-collapsed' : ''"
|
||||
>
|
||||
<virtual-list
|
||||
ref="log-view"
|
||||
class="log-view"
|
||||
:data-key="'index'"
|
||||
:data-sources="items"
|
||||
:data-component="itemComponent"
|
||||
:keeps="taskLogPageSize"
|
||||
:start="currentLogIndex - 1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="!isErrorsCollapsed && !isErrorCollapsing"
|
||||
class="errors-wrapper"
|
||||
:class="isErrorsCollapsed ? 'collapsed' : ''"
|
||||
>
|
||||
<ul class="error-list">
|
||||
<li
|
||||
v-for="item in errorLogData"
|
||||
:key="item.index"
|
||||
class="error-item"
|
||||
:class="currentLogIndex === item.index ? 'active' : ''"
|
||||
@click="onClickError(item)"
|
||||
>
|
||||
<span class="line-content">
|
||||
{{ item.msg }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState,
|
||||
mapGetters
|
||||
} from 'vuex'
|
||||
import VirtualList from 'vue-virtual-scroll-list'
|
||||
import Convert from 'ansi-to-html'
|
||||
import hasAnsi from 'has-ansi'
|
||||
|
||||
import LogItem from './LogItem'
|
||||
|
||||
const convert = new Convert()
|
||||
export default {
|
||||
name: 'LogView',
|
||||
components: {
|
||||
VirtualList
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
itemComponent: LogItem,
|
||||
searchString: '',
|
||||
isScrolling: false,
|
||||
isScrolling2nd: false,
|
||||
errorRegex: this.$utils.log.errorRegex,
|
||||
currentOffset: 0,
|
||||
isErrorsCollapsed: true,
|
||||
isErrorCollapsing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('task', [
|
||||
'taskForm',
|
||||
'taskLogTotal',
|
||||
'logKeyword',
|
||||
'isLogFetchLoading',
|
||||
'errorLogData'
|
||||
]),
|
||||
...mapGetters('task', [
|
||||
'logData'
|
||||
]),
|
||||
currentLogIndex: {
|
||||
get() {
|
||||
return this.$store.state.task.currentLogIndex
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_CURRENT_LOG_INDEX', value)
|
||||
}
|
||||
},
|
||||
logKeyword: {
|
||||
get() {
|
||||
return this.$store.state.task.logKeyword
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_LOG_KEYWORD', value)
|
||||
}
|
||||
},
|
||||
taskLogPage: {
|
||||
get() {
|
||||
return this.$store.state.task.taskLogPage
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_TASK_LOG_PAGE', value)
|
||||
}
|
||||
},
|
||||
taskLogPageSize: {
|
||||
get() {
|
||||
return this.$store.state.task.taskLogPageSize
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_TASK_LOG_PAGE_SIZE', value)
|
||||
}
|
||||
},
|
||||
isLogAutoScroll: {
|
||||
get() {
|
||||
return this.$store.state.task.isLogAutoScroll
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_IS_LOG_AUTO_SCROLL', value)
|
||||
}
|
||||
},
|
||||
isLogAutoFetch: {
|
||||
get() {
|
||||
return this.$store.state.task.isLogAutoFetch
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_IS_LOG_AUTO_FETCH', value)
|
||||
}
|
||||
},
|
||||
isLogFetchLoading: {
|
||||
get() {
|
||||
return this.$store.state.task.isLogFetchLoading
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit('task/SET_IS_LOG_FETCH_LOADING', value)
|
||||
}
|
||||
},
|
||||
items() {
|
||||
if (!this.logData || this.logData.length === 0) {
|
||||
return []
|
||||
}
|
||||
const filteredLogData = this.logData.filter(d => {
|
||||
if (!this.searchString) return true
|
||||
return !!d.data.toLowerCase().match(this.searchString.toLowerCase())
|
||||
})
|
||||
return filteredLogData.map(logItem => {
|
||||
const isAnsi = hasAnsi(logItem.data)
|
||||
return {
|
||||
index: logItem.index,
|
||||
data: isAnsi ? convert.toHtml(logItem.data) : logItem.data,
|
||||
searchString: this.logKeyword,
|
||||
active: logItem.active,
|
||||
isAnsi
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
taskLogPage() {
|
||||
this.$emit('search')
|
||||
this.$st.sendEv('任务详情', '日志', '改变页数')
|
||||
},
|
||||
taskLogPageSize() {
|
||||
this.$emit('search')
|
||||
this.$st.sendEv('任务详情', '日志', '改变日志每页条数')
|
||||
},
|
||||
isLogAutoScroll() {
|
||||
if (this.isLogAutoScroll) {
|
||||
this.$store.dispatch('task/getTaskLog', {
|
||||
id: this.$route.params.id,
|
||||
keyword: this.logKeyword
|
||||
}).then(() => {
|
||||
this.toBottom()
|
||||
})
|
||||
this.$st.sendEv('任务详情', '日志', '点击自动滚动')
|
||||
} else {
|
||||
this.$st.sendEv('任务详情', '日志', '取消自动滚动')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.currentLogIndex = 0
|
||||
this.handle = setInterval(() => {
|
||||
if (this.isLogAutoScroll) {
|
||||
this.toBottom()
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.handle)
|
||||
},
|
||||
methods: {
|
||||
toBottom() {
|
||||
this.$el.querySelector('.log-view').scrollTo({ top: 99999999 })
|
||||
},
|
||||
toggleErrors() {
|
||||
this.isErrorsCollapsed = !this.isErrorsCollapsed
|
||||
this.isErrorCollapsing = true
|
||||
setTimeout(() => {
|
||||
this.isErrorCollapsing = false
|
||||
}, 300)
|
||||
},
|
||||
async onClickError(item) {
|
||||
const page = Math.ceil(item.seq / this.taskLogPageSize)
|
||||
this.$store.commit('task/SET_LOG_KEYWORD', '')
|
||||
this.$store.commit('task/SET_TASK_LOG_PAGE', page)
|
||||
this.$store.commit('task/SET_IS_LOG_AUTO_SCROLL', false)
|
||||
this.$store.commit('task/SET_ACTIVE_ERROR_LOG_ITEM', item)
|
||||
this.$emit('search')
|
||||
this.$st.sendEv('任务详情', '日志', '点击错误日志')
|
||||
},
|
||||
onSearchLog() {
|
||||
this.$emit('search')
|
||||
this.$st.sendEv('任务详情', '日志', '搜索日志')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.log-view-wrapper {
|
||||
float: left;
|
||||
flex-basis: calc(100% - 240px);
|
||||
width: calc(100% - 300px);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.log-view-wrapper.errors-collapsed {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-view {
|
||||
margin-top: 0 !important;
|
||||
overflow-y: scroll !important;
|
||||
height: 600px;
|
||||
list-style: none;
|
||||
color: #A9B7C6;
|
||||
background: #2B2B2B;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.errors-wrapper {
|
||||
float: left;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-basis: 240px;
|
||||
width: 300px;
|
||||
transition: opacity 0.3s;
|
||||
border-top: 1px solid #DCDFE6;
|
||||
border-right: 1px solid #DCDFE6;
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
height: calc(100vh - 240px);
|
||||
font-size: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.errors-wrapper.collapsed {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list .error-item {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
/*height: 18px;*/
|
||||
border-bottom: 1px solid white;
|
||||
padding: 5px 0;
|
||||
background: #F56C6C;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list .error-item.active {
|
||||
background: #E6A23C;
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list .error-item:hover {
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list .error-item .line-no {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.errors-wrapper .error-list .error-item .line-content {
|
||||
display: inline;
|
||||
width: calc(100% - 70px);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right .el-pagination {
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,480 +0,0 @@
|
||||
<template>
|
||||
<el-tabs
|
||||
v-model="activeTabName"
|
||||
class="git-settings"
|
||||
@change="onChangeTab"
|
||||
>
|
||||
<el-tab-pane :label="$t('Settings')" name="settings">
|
||||
<el-form
|
||||
ref="git-settings-form"
|
||||
class="git-settings-form"
|
||||
label-width="150px"
|
||||
:model="spiderForm"
|
||||
>
|
||||
<el-form-item
|
||||
:label="$t('Git URL')"
|
||||
prop="git_url"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model="spiderForm.git_url"
|
||||
:placeholder="$t('Git URL')"
|
||||
@blur="onGitUrlChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('Has Credential')"
|
||||
prop="git_has_credential"
|
||||
>
|
||||
<el-switch
|
||||
v-model="spiderForm.git_has_credential"
|
||||
size="small"
|
||||
active-color="#67C23A"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.git_has_credential"
|
||||
:label="$t('Git Username')"
|
||||
prop="git_username"
|
||||
>
|
||||
<el-input
|
||||
v-model="spiderForm.git_username"
|
||||
:placeholder="$t('Git Username')"
|
||||
@blur="onGitUrlChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.git_has_credential"
|
||||
:label="$t('Git Password')"
|
||||
prop="git_password"
|
||||
>
|
||||
<el-input
|
||||
v-model="spiderForm.git_password"
|
||||
:placeholder="$t('Git Password')"
|
||||
type="password"
|
||||
@blur="onGitUrlChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('Git Branch')"
|
||||
prop="git_branch"
|
||||
required
|
||||
>
|
||||
<el-select
|
||||
v-model="spiderForm.git_branch"
|
||||
:placeholder="$t('Git Branch')"
|
||||
:disabled="!spiderForm.git_url || isGitBranchesLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in gitBranches"
|
||||
:key="op"
|
||||
:value="op"
|
||||
:label="op"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('Auto Sync')"
|
||||
prop="git_auto_sync"
|
||||
>
|
||||
<el-switch
|
||||
v-model="spiderForm.git_auto_sync"
|
||||
size="small"
|
||||
active-color="#67C23A"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.git_auto_sync"
|
||||
:label="$t('Sync Frequency')"
|
||||
prop="git_sync_frequency"
|
||||
required
|
||||
>
|
||||
<el-select
|
||||
v-model="spiderForm.git_sync_frequency"
|
||||
:placeholder="$t('Sync Frequency')"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in syncFrequencies"
|
||||
:key="op.value"
|
||||
:value="op.value"
|
||||
:label="op.label"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="spiderForm.git_sync_error"
|
||||
:label="$t('Error Message')"
|
||||
prop="git_git_sync_error"
|
||||
>
|
||||
<el-alert
|
||||
type="error"
|
||||
:closable="false"
|
||||
>
|
||||
{{ spiderForm.git_sync_error }}
|
||||
</el-alert>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="sshPublicKey"
|
||||
:label="$t('SSH Public Key')"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
>
|
||||
{{ sshPublicKey }}
|
||||
</el-alert>
|
||||
<span class="copy" @click="copySshPublicKey">
|
||||
<i class="el-icon-copy-document" />
|
||||
{{ $t('Copy') }}
|
||||
</span>
|
||||
<input v-show="true" id="ssh-public-key" v-model="sshPublicKey">
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-wrapper">
|
||||
<el-button
|
||||
size="small"
|
||||
type="warning"
|
||||
:disabled="isGitResetLoading"
|
||||
:icon="isGitResetLoading ? 'el-icon-loading' : 'el-icon-refresh-left'"
|
||||
@click="onReset"
|
||||
>
|
||||
{{ $t('Reset') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="isGitSyncLoading ? 'el-icon-loading' : 'el-icon-refresh'"
|
||||
:disabled="!spiderForm.git_url || isGitSyncLoading"
|
||||
@click="onSync"
|
||||
>
|
||||
{{ $t('Sync') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="success" icon="el-icon-check" @click="onSave">
|
||||
{{ $t('Save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Log" name="log">
|
||||
<el-timeline
|
||||
class="log"
|
||||
>
|
||||
<el-timeline-item
|
||||
v-for="c in commits"
|
||||
:key="c.hash"
|
||||
:timestamp="c.ts"
|
||||
:type="getCommitType(c)"
|
||||
>
|
||||
<div class="commit">
|
||||
<div class="row">
|
||||
<div class="message">
|
||||
{{ c.message }}
|
||||
</div>
|
||||
<div class="author">
|
||||
{{ c.author }} ({{ c.email }})
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px">
|
||||
<div class="tags">
|
||||
<el-tag
|
||||
v-if="c.is_head"
|
||||
type="primary"
|
||||
size="mini"
|
||||
>
|
||||
<i class="fa fa-tag" />
|
||||
HEAD
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-for="b in c.branches"
|
||||
:key="b.name"
|
||||
:type="b.label === 'master' ? 'danger' : 'warning'"
|
||||
size="mini"
|
||||
>
|
||||
<i class="fa fa-tag" />
|
||||
{{ b.label }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-for="b in c.remote_branches"
|
||||
:key="b.name"
|
||||
type="info"
|
||||
size="mini"
|
||||
>
|
||||
<i class="fa fa-tag" />
|
||||
{{ b.label }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-for="t in c.tags"
|
||||
:key="t.name"
|
||||
type="success"
|
||||
size="mini"
|
||||
>
|
||||
<i class="fa fa-tag" />
|
||||
{{ t.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button
|
||||
v-if="!c.is_head"
|
||||
type="danger"
|
||||
:icon="isGitCheckoutLoading ? 'el-icon-loading' : 'el-icon-position'"
|
||||
size="mini"
|
||||
@click="checkout(c)"
|
||||
>
|
||||
Checkout
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'GitSettings',
|
||||
data() {
|
||||
return {
|
||||
gitBranches: [],
|
||||
isGitBranchesLoading: false,
|
||||
isGitSyncLoading: false,
|
||||
isGitResetLoading: false,
|
||||
isGitCheckoutLoading: false,
|
||||
syncFrequencies: [
|
||||
{ label: '1m', value: '0 * * * * *' },
|
||||
{ label: '5m', value: '0 0/5 * * * *' },
|
||||
{ label: '15m', value: '0 0/15 * * * *' },
|
||||
{ label: '30m', value: '0 0/30 * * * *' },
|
||||
{ label: '1h', value: '0 0 * * * *' },
|
||||
{ label: '6h', value: '0 0 0/6 * * *' },
|
||||
{ label: '12h', value: '0 0 0/12 * * *' },
|
||||
{ label: '1d', value: '0 0 0 0 * *' }
|
||||
],
|
||||
sshPublicKey: '',
|
||||
activeTabName: 'settings',
|
||||
commits: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
])
|
||||
},
|
||||
async created() {
|
||||
if (this.spiderForm.git_url) {
|
||||
this.onGitUrlChange()
|
||||
}
|
||||
this.getSshPublicKey()
|
||||
this.getCommits()
|
||||
},
|
||||
methods: {
|
||||
onSave() {
|
||||
this.$refs['git-settings-form'].validate(async valid => {
|
||||
if (!valid) return
|
||||
const res = await this.$store.dispatch('spider/editSpider')
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Spider info has been saved successfully'))
|
||||
}
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', 'Git 设置', '保存')
|
||||
},
|
||||
async onGitUrlChange() {
|
||||
if (!this.spiderForm.git_url) return
|
||||
this.isGitBranchesLoading = true
|
||||
try {
|
||||
const res = await this.$request.get('/git/branches', {
|
||||
url: this.spiderForm.git_url,
|
||||
username: this.spiderForm.git_username,
|
||||
password: this.spiderForm.git_password
|
||||
})
|
||||
this.gitBranches = res.data.data
|
||||
if (!this.spiderForm.git_branch && this.gitBranches.length > 0) {
|
||||
this.$set(this.spiderForm, 'git_branch', this.gitBranches[0])
|
||||
}
|
||||
} finally {
|
||||
this.isGitBranchesLoading = false
|
||||
}
|
||||
},
|
||||
async onSync() {
|
||||
this.isGitSyncLoading = true
|
||||
try {
|
||||
const res = await this.$request.post(`/spiders/${this.spiderForm._id}/git/sync`)
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Git has been synchronized successfully'))
|
||||
}
|
||||
} finally {
|
||||
this.isGitSyncLoading = false
|
||||
await this.updateGit()
|
||||
await this.$store.dispatch('spider/getSpiderData', this.$route.params.id)
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Git 设置', '同步')
|
||||
},
|
||||
onReset() {
|
||||
this.$confirm(
|
||||
this.$t('This would delete all files of the spider. Are you sure to continue?'),
|
||||
this.$t('Notification'),
|
||||
{
|
||||
confirmButtonText: this.$t('Confirm'),
|
||||
cancelButtonText: this.$t('Cancel'),
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async() => {
|
||||
this.isGitResetLoading = true
|
||||
try {
|
||||
const res = await this.$request.post(`/spiders/${this.spiderForm._id}/git/reset`)
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Git has been reset successfully'))
|
||||
this.$st.sendEv('爬虫详情', 'Git 设置', '确认重置')
|
||||
}
|
||||
} finally {
|
||||
this.isGitResetLoading = false
|
||||
// await this.updateGit()
|
||||
}
|
||||
})
|
||||
this.$st.sendEv('爬虫详情', 'Git 设置', '点击重置')
|
||||
},
|
||||
async getSshPublicKey() {
|
||||
const res = await this.$request.get('/git/public-key')
|
||||
this.sshPublicKey = res.data.data
|
||||
},
|
||||
copySshPublicKey() {
|
||||
const el = document.querySelector('#ssh-public-key')
|
||||
el.focus()
|
||||
el.setSelectionRange(0, this.sshPublicKey.length)
|
||||
document.execCommand('copy')
|
||||
this.$message.success(this.$t('SSH Public Key is copied to the clipboard'))
|
||||
this.$st.sendEv('爬虫详情', 'Git 设置', '拷贝 SSH 公钥')
|
||||
},
|
||||
async getCommits() {
|
||||
const res = await this.$request.get('/git/commits', { spider_id: this.spiderForm._id })
|
||||
this.commits = res.data.data.map(d => {
|
||||
d.ts = dayjs(d.ts).format('YYYY-MM-DD HH:mm:ss')
|
||||
return d
|
||||
})
|
||||
},
|
||||
async checkout(c) {
|
||||
this.isGitCheckoutLoading = true
|
||||
try {
|
||||
const res = await this.$request.post('/git/checkout', { spider_id: this.spiderForm._id, hash: c.hash })
|
||||
if (!res.data.error) {
|
||||
this.$message.success(this.$t('Checkout success'))
|
||||
}
|
||||
} finally {
|
||||
this.isGitCheckoutLoading = false
|
||||
await this.getCommits()
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', 'Git Log', 'Checkout')
|
||||
},
|
||||
async updateGit() {
|
||||
this.getCommits()
|
||||
},
|
||||
getCommitType(c) {
|
||||
if (c.is_head) return 'primary'
|
||||
if (c.branches && c.branches.length) {
|
||||
if (c.branches.map(d => d.label).includes('master')) {
|
||||
return 'danger'
|
||||
} else {
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
if (c.tags && c.tags.length) {
|
||||
return 'success'
|
||||
}
|
||||
if (c.remote_branches && c.remote_branches.length) {
|
||||
return 'info'
|
||||
}
|
||||
},
|
||||
onChangeTab() {
|
||||
this.$st.sendEv('爬虫详情', 'Git 切换标签', this.activeTabName)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.git-settings {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.git-settings .git-settings-form {
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.git-settings .git-settings-form >>> .el-alert {
|
||||
padding: 0 5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.git-settings .git-settings-form >>> .el-alert__description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.git-settings .git-settings-form .copy {
|
||||
display: inline;
|
||||
line-height: 14px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-settings .git-settings-form .copy {
|
||||
}
|
||||
|
||||
#ssh-public-key {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 0;
|
||||
/*visibility: hidden;*/
|
||||
}
|
||||
|
||||
.git-settings .title {
|
||||
border-bottom: 1px solid #DCDFE6;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.git-settings .action-wrapper {
|
||||
width: 640px;
|
||||
text-align: right;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.git-settings .log {
|
||||
height: calc(100vh - 280px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.git-settings .log .commit {
|
||||
border-top: 1px solid rgb(244, 244, 245);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.git-settings .log .commit .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.git-settings .log .el-timeline-item {
|
||||
/*cursor: pointer;*/
|
||||
}
|
||||
|
||||
.git-settings .log .commit .row .tags .el-tag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.git-settings .log .commit .row .actions {
|
||||
right: 0;
|
||||
bottom: 5px;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div :class="{active:isActive}" class="share-dropdown-menu">
|
||||
<div class="share-dropdown-menu-wrapper">
|
||||
<span class="share-dropdown-menu-title" @click.self="clickTitle">{{ title }}</span>
|
||||
<div v-for="(item,index) of items" :key="index" class="share-dropdown-menu-item">
|
||||
<a v-if="item.href" :href="item.href" target="_blank">{{ item.title }}</a>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: function() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'vue'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isActive: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickTitle() {
|
||||
this.isActive = !this.isActive
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss" >
|
||||
$n: 8; //和items.length 相同
|
||||
$t: .1s;
|
||||
.share-dropdown-menu {
|
||||
width: 250px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
&-title {
|
||||
width: 100%;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: black;
|
||||
color: white;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
transform: translate3d(0,0,0);
|
||||
}
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
&-item {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: #e0e0e0;
|
||||
line-height: 60px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
opacity: 1;
|
||||
transition: transform 0.28s ease;
|
||||
&:hover {
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
@for $i from 1 through $n {
|
||||
&:nth-of-type(#{$i}) {
|
||||
z-index: -1;
|
||||
transition-delay: $i*$t;
|
||||
transform: translate3d(0, -60px, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
.share-dropdown-menu-wrapper {
|
||||
z-index: 1;
|
||||
}
|
||||
.share-dropdown-menu-item {
|
||||
@for $i from 1 through $n {
|
||||
&:nth-of-type(#{$i}) {
|
||||
transition-delay: ($n - $i)*$t;
|
||||
transform: translate3d(0, ($i - 1)*60px, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleSetSize">
|
||||
<div>
|
||||
<svg-icon class-name="size-icon" icon-class="size" />
|
||||
</div>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size===item.value" :command="item.value">{{
|
||||
item.label }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
sizeOptions: [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'Small', value: 'small' },
|
||||
{ label: 'Mini', value: 'mini' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
size() {
|
||||
return this.$store.getters.size
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleSetSize(size) {
|
||||
this.$ELEMENT.size = size
|
||||
this.$store.dispatch('setSize', size)
|
||||
this.refreshView()
|
||||
this.$message({
|
||||
message: 'Switch Size Success',
|
||||
type: 'success'
|
||||
})
|
||||
},
|
||||
refreshView() {
|
||||
// In order to make the cached page re-rendered
|
||||
this.$store.dispatch('delAllCachedViews', this.$route)
|
||||
|
||||
const { fullPath } = this.$route
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$router.replace({
|
||||
path: '/redirect' + fullPath
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
ref="form"
|
||||
class="copy-spider-dialog"
|
||||
:title="$t('Copy Spider')"
|
||||
:visible="visible"
|
||||
width="580px"
|
||||
:before-close="onClose"
|
||||
>
|
||||
<el-form
|
||||
ref="form"
|
||||
label-width="160px"
|
||||
:model="form"
|
||||
>
|
||||
<el-form-item
|
||||
:label="$t('New Spider Name')"
|
||||
required
|
||||
>
|
||||
<el-input v-model="form.name" :placeholder="$t('New Spider Name')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template slot="footer">
|
||||
<el-button type="plain" size="small" @click="$emit('close')">{{ $t('Cancel') }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="isLoading ? 'el-icon-loading' : ''"
|
||||
:disabled="isLoading"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('Confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CopySpiderDialog',
|
||||
props: {
|
||||
spiderId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
name: ''
|
||||
},
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close')
|
||||
},
|
||||
onConfirm() {
|
||||
this.$refs['form'].validate(async valid => {
|
||||
if (!valid) return
|
||||
try {
|
||||
this.isLoading = true
|
||||
const res = await this.$request.post(`/spiders/${this.spiderId}/copy`, this.form)
|
||||
if (!res.data.error) {
|
||||
this.$message.success('Copied successfully')
|
||||
}
|
||||
this.$emit('confirm')
|
||||
this.$emit('close')
|
||||
this.$st.sendEv('爬虫复制', '确认提交')
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
45
frontend/src/components/Spider/CreateEditSpiderDialog.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<CreateEditDialog
|
||||
:type="activeDialogKey"
|
||||
:tab-name="createEditDialogTabName"
|
||||
:visible="createEditDialogVisible"
|
||||
:action-functions="actionFunctions"
|
||||
:batch-form-data="formList"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:confirm-loading="confirmLoading"
|
||||
:batch-form-fields="batchFormFields"
|
||||
:form-rules="formRules"
|
||||
>
|
||||
<template #default>
|
||||
<SpiderForm/>
|
||||
</template>
|
||||
</CreateEditDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
|
||||
import SpiderForm from '@/components/spider/SpiderForm.vue';
|
||||
import useSpider from '@/components/spider/spider';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CreateSpiderDialog',
|
||||
components: {
|
||||
CreateEditDialog,
|
||||
SpiderForm,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
...useSpider(store),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
186
frontend/src/components/Spider/RunSpiderDialog.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:title="title"
|
||||
:visible="visible"
|
||||
@close="onClose"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="options"
|
||||
>
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Command" prop="cmd" required>
|
||||
<InputWithButton
|
||||
v-model="options.cmd"
|
||||
:button-icon="['fa', 'edit']"
|
||||
button-label="Edit"
|
||||
placeholder="Command"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Param" prop="param">
|
||||
<InputWithButton
|
||||
v-model="options.param"
|
||||
:button-icon="['fa', 'edit']"
|
||||
button-label="Edit"
|
||||
placeholder="Params"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Mode" prop="mode" required>
|
||||
<el-select
|
||||
v-model="options.mode"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in modeOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Priority" prop="priority" required>
|
||||
<el-select
|
||||
v-model="options.priority"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in priorityOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<FormItem
|
||||
v-if="options.mode === TASK_MODE_SELECTED_NODE_TAGS"
|
||||
:span="4"
|
||||
label="Selected Tags"
|
||||
prop="node_tags"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="options.node_tags"
|
||||
:options="allNodeTags"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
v-if="[TASK_MODE_SELECTED_NODES, TASK_MODE_SELECTED_NODE_TAGS].includes(options.mode)"
|
||||
:span="4"
|
||||
label="Selected Nodes"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="options.node_ids"
|
||||
:options="allNodeSelectOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, ref} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import Dialog from '@/components/dialog/Dialog.vue';
|
||||
import Form from '@/components/form/Form.vue';
|
||||
import useSpider from '@/components/spider/spider';
|
||||
import useNode from '@/components/node/node';
|
||||
import {TASK_MODE_RANDOM, TASK_MODE_SELECTED_NODE_TAGS, TASK_MODE_SELECTED_NODES} from '@/constants/task';
|
||||
import useTask from '@/components/task/task';
|
||||
import FormItem from '@/components/form/FormItem.vue';
|
||||
import InputWithButton from '@/components/input/InputWithButton.vue';
|
||||
import CheckTagGroup from '@/components/tag/CheckTagGroup.vue';
|
||||
import {ElMessage} from 'element-plus';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RunSpiderDialog',
|
||||
components: {
|
||||
Dialog,
|
||||
Form,
|
||||
FormItem,
|
||||
InputWithButton,
|
||||
CheckTagGroup,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const ns = 'spider';
|
||||
const store = useStore();
|
||||
const {
|
||||
spider: state,
|
||||
} = store.state as RootStoreState;
|
||||
|
||||
const {
|
||||
allListSelectOptions: allNodeSelectOptions,
|
||||
allTags: allNodeTags,
|
||||
} = useNode(store);
|
||||
|
||||
const {
|
||||
modeOptions,
|
||||
form,
|
||||
} = useSpider(store);
|
||||
|
||||
const {
|
||||
priorityOptions,
|
||||
} = useTask(store);
|
||||
|
||||
// spider
|
||||
const spider = computed<Spider>(() => form.value);
|
||||
|
||||
// form ref
|
||||
const formRef = ref<typeof Form>();
|
||||
|
||||
// run options
|
||||
const options = ref<SpiderRunOptions>({
|
||||
mode: TASK_MODE_RANDOM,
|
||||
cmd: spider.value.cmd,
|
||||
param: spider.value.param,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
// dialog visible
|
||||
const visible = computed<boolean>(() => state.activeDialogKey === 'run');
|
||||
|
||||
// title
|
||||
const title = computed<string>(() => {
|
||||
if (!spider.value) return 'Run Spider';
|
||||
return `Run Spider - ${spider.value.name}`;
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
store.commit(`${ns}/hideDialog`);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
await formRef.value?.validate();
|
||||
await store.dispatch(`${ns}/runById`, {id: spider.value?._id, options: options.value});
|
||||
store.commit(`${ns}/hideDialog`);
|
||||
await ElMessage.success('Scheduled task successfully');
|
||||
await store.dispatch(`${ns}/getList`);
|
||||
};
|
||||
|
||||
return {
|
||||
TASK_MODE_SELECTED_NODES,
|
||||
TASK_MODE_SELECTED_NODE_TAGS,
|
||||
visible,
|
||||
title,
|
||||
formRef,
|
||||
options,
|
||||
modeOptions,
|
||||
allNodeSelectOptions,
|
||||
allNodeTags,
|
||||
priorityOptions,
|
||||
onClose,
|
||||
onConfirm,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
198
frontend/src/components/Spider/SpiderForm.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<Form v-if="form" ref="formRef" :model="form">
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Name" prop="name" required>
|
||||
<el-input v-model="form.name" :disabled="isFormItemDisabled('name')" placeholder="Name"/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Project" prop="project_id">
|
||||
<el-select
|
||||
v-model="form.project_id"
|
||||
:disabled="isFormItemDisabled('project_id')"
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="op in allProjectSelectOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Command" prop="cmd" required>
|
||||
<InputWithButton
|
||||
v-model="form.cmd"
|
||||
:button-icon="['fa', 'edit']"
|
||||
button-label="Edit"
|
||||
placeholder="Command"
|
||||
:disabled="isFormItemDisabled('cmd')"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Param" prop="param">
|
||||
<InputWithButton
|
||||
v-model="form.param"
|
||||
:button-icon="['fa', 'edit']"
|
||||
button-label="Edit"
|
||||
placeholder="Params"
|
||||
:disabled="isFormItemDisabled('param')"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<!-- Row -->
|
||||
<FormItem :span="2" label="Default Mode" prop="mode" required>
|
||||
<el-select
|
||||
v-model="form.mode"
|
||||
:disabled="isFormItemDisabled('mode')"
|
||||
>
|
||||
<el-option
|
||||
v-for="op in modeOptions"
|
||||
:key="op.value"
|
||||
:label="op.label"
|
||||
:value="op.value"
|
||||
/>
|
||||
</el-select>
|
||||
</FormItem>
|
||||
<FormItem :span="2" label="Results Collection" prop="col_name" required>
|
||||
<el-autocomplete
|
||||
v-model="form.col_name"
|
||||
:disabled="isFormItemDisabled('col_name')"
|
||||
placeholder="Results Collection"
|
||||
:fetch-suggestions="fetchDataCollectionSuggestions"
|
||||
@input="onDataCollectionInput"
|
||||
@select="onDataCollectionSuggestionSelect"
|
||||
/>
|
||||
</FormItem>
|
||||
<!-- ./Row -->
|
||||
|
||||
<FormItem
|
||||
v-if="form.mode === TASK_MODE_SELECTED_NODE_TAGS"
|
||||
:span="4"
|
||||
label="Selected Tags"
|
||||
prop="node_tags"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="form.node_tags"
|
||||
:options="allNodeTags"
|
||||
:disabled="isFormItemDisabled('node_tags')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
v-if="[TASK_MODE_SELECTED_NODES, TASK_MODE_SELECTED_NODE_TAGS].includes(form.mode)"
|
||||
:span="4"
|
||||
label="Selected Nodes"
|
||||
required
|
||||
>
|
||||
<CheckTagGroup
|
||||
v-model="form.node_ids"
|
||||
:options="allNodeSelectOptions"
|
||||
:disabled="form.mode === TASK_MODE_SELECTED_NODE_TAGS && isFormItemDisabled('node_ids')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :span="4" label="Description" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
:disabled="isFormItemDisabled('description')"
|
||||
placeholder="Description"
|
||||
type="textarea"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, watch} from 'vue';
|
||||
import {useStore} from 'vuex';
|
||||
import useSpider from '@/components/spider/spider';
|
||||
import useNode from '@/components/node/node';
|
||||
import useProject from '@/components/project/project';
|
||||
import Form from '@/components/form/Form.vue';
|
||||
import FormItem from '@/components/form/FormItem.vue';
|
||||
import InputWithButton from '@/components/input/InputWithButton.vue';
|
||||
import CheckTagGroup from '@/components/tag/CheckTagGroup.vue';
|
||||
import {TASK_MODE_SELECTED_NODE_TAGS, TASK_MODE_SELECTED_NODES} from '@/constants/task';
|
||||
import pinyin, {STYLE_NORMAL} from 'pinyin';
|
||||
import {isZeroObjectId} from '@/utils/mongo';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SpiderForm',
|
||||
components: {
|
||||
Form,
|
||||
FormItem,
|
||||
InputWithButton,
|
||||
CheckTagGroup,
|
||||
},
|
||||
setup() {
|
||||
// store
|
||||
const store = useStore();
|
||||
|
||||
// use node
|
||||
const {
|
||||
allListSelectOptions: allNodeSelectOptions,
|
||||
allTags: allNodeTags,
|
||||
} = useNode(store);
|
||||
|
||||
// use project
|
||||
const {
|
||||
allListSelectOptionsWithEmpty: allProjectSelectOptions,
|
||||
} = useProject(store);
|
||||
|
||||
// use spider
|
||||
const {
|
||||
form,
|
||||
} = useSpider(store);
|
||||
|
||||
// whether col field of form has been changed
|
||||
const isFormColChanged = ref<boolean>(false);
|
||||
|
||||
const onColInput = () => {
|
||||
isFormColChanged.value = true;
|
||||
};
|
||||
|
||||
watch(() => form.value?.name, () => {
|
||||
if (isFormColChanged.value) return;
|
||||
if (form.value?._id && isZeroObjectId(form.value?._id)) return;
|
||||
if (!form.value.name) {
|
||||
form.value.col_name = '';
|
||||
} else {
|
||||
const name = pinyin(form.value.name, {style: STYLE_NORMAL})
|
||||
.map(d => d.join('_'))
|
||||
.join('_');
|
||||
form.value.col_name = `results_${name}`;
|
||||
}
|
||||
});
|
||||
|
||||
const onDataCollectionSuggestionSelect = ({_id}: { _id: string; value: string }) => {
|
||||
form.value.col_id = _id;
|
||||
};
|
||||
|
||||
const onDataCollectionInput = (value: string) => {
|
||||
form.value.col_name = value;
|
||||
form.value.col_id = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
...useSpider(store),
|
||||
|
||||
// custom
|
||||
TASK_MODE_SELECTED_NODES,
|
||||
TASK_MODE_SELECTED_NODE_TAGS,
|
||||
allNodeSelectOptions,
|
||||
allNodeTags,
|
||||
allProjectSelectOptions,
|
||||
onColInput,
|
||||
onDataCollectionSuggestionSelect,
|
||||
onDataCollectionInput,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
100
frontend/src/components/Spider/SpiderStat.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="spider-stat">
|
||||
<Tag
|
||||
:icon="['fa', 'tasks']"
|
||||
:label="labels.tasks"
|
||||
:tooltip="tooltips.tasks"
|
||||
type="primary"
|
||||
/>
|
||||
<Tag
|
||||
:icon="['fa', 'database']"
|
||||
:label="labels.results"
|
||||
:tooltip="tooltips.results"
|
||||
type="success"
|
||||
/>
|
||||
<Tag
|
||||
:icon="['fa', 'stopwatch']"
|
||||
:label="labels.duration"
|
||||
type="warning"
|
||||
>
|
||||
<template #tooltip>
|
||||
<div v-html="tooltips.duration"/>
|
||||
</template>
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import Tag from '@/components/tag/Tag.vue';
|
||||
import colors from '@/styles/color.scss';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SpiderStats',
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
props: {
|
||||
stat: {
|
||||
type: Object as PropType<SpiderStat>,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setup(props: SpiderStatProps, {emit}) {
|
||||
const labels = computed<SpiderStatLabels>(() => {
|
||||
const {stat} = props;
|
||||
const {
|
||||
tasks,
|
||||
results,
|
||||
// average_wait_duration,
|
||||
// average_runtime_duration,
|
||||
average_total_duration,
|
||||
} = stat as SpiderStat;
|
||||
|
||||
return {
|
||||
tasks: `${tasks}`,
|
||||
results: `${results}`,
|
||||
duration: `${average_total_duration}`
|
||||
};
|
||||
});
|
||||
|
||||
const tooltips = computed<SpiderStatTooltips>(() => {
|
||||
const {stat} = props;
|
||||
const {
|
||||
tasks,
|
||||
results,
|
||||
average_wait_duration,
|
||||
average_runtime_duration,
|
||||
average_total_duration,
|
||||
} = stat as SpiderStat;
|
||||
|
||||
return {
|
||||
tasks: `Total Tasks: ${tasks}`,
|
||||
results: `Total Results: ${results}`,
|
||||
duration: `
|
||||
<span class="label">Average Wait Duration:</span>
|
||||
<span class="value" style="color: ${colors.blue}">${humanizeDuration(average_wait_duration * 1000, {spacer: ' '})}</span><br>
|
||||
<span class="label">Average Runtime Duration:</span>
|
||||
<span class="value" style="color: ${colors.orange}">${humanizeDuration(average_runtime_duration * 1000, {spacer: ' '})}</span><br>
|
||||
<span class="label">Average Total Duration:</span>
|
||||
<span class="value" style="color: ${colors.white}">${humanizeDuration(average_total_duration * 1000, {spacer: ' '})}</span><br>
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tooltips,
|
||||
labels,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.spider-stat {
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
38
frontend/src/components/Spider/SpiderTag.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<LinkTag
|
||||
:label="spider.name"
|
||||
:path="path"
|
||||
type="primary"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import LinkTag from '@/components/tag/LinkTag.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SpiderTag',
|
||||
components: {LinkTag},
|
||||
props: {
|
||||
spider: {
|
||||
type: Object as PropType<Spider>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props: SpiderTagProps, {emit}) {
|
||||
const path = computed<string>(() => {
|
||||
const {spider} = props;
|
||||
const {_id} = spider;
|
||||
return `/spiders/${_id}`;
|
||||
});
|
||||
|
||||
return {
|
||||
path,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
66
frontend/src/components/Spider/SpiderType.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Tag
|
||||
:icon="data.icon"
|
||||
:label="data.label"
|
||||
:tooltip="data.tooltip"
|
||||
:type="data.type"
|
||||
width="100px"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from 'vue';
|
||||
import {SPIDER_TYPE_CONFIGURABLE, SPIDER_TYPE_CUSTOMIZED} from '@/constants/spider';
|
||||
import Tag from '@/components/tag/Tag.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SpiderType',
|
||||
components: {
|
||||
Tag,
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<SpiderType>,
|
||||
default: SPIDER_TYPE_CUSTOMIZED,
|
||||
}
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props: SpiderTypeProps, {emit}) {
|
||||
const data = computed<TagData>(() => {
|
||||
const {type} = props;
|
||||
switch (type) {
|
||||
case SPIDER_TYPE_CUSTOMIZED:
|
||||
return {
|
||||
tooltip: 'Customized Spider',
|
||||
label: 'Customized',
|
||||
type: 'success',
|
||||
icon: ['fa', 'code'],
|
||||
};
|
||||
case SPIDER_TYPE_CONFIGURABLE:
|
||||
return {
|
||||
label: 'Configurable',
|
||||
tooltip: 'Configurable Spider',
|
||||
type: 'primary',
|
||||
icon: ['fa', 'cog'],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
tooltip: 'Unknown Type',
|
||||
type: 'info',
|
||||
icon: ['fa', 'question'],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
130
frontend/src/components/Spider/spider.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {useRoute} from 'vue-router';
|
||||
import {computed} from 'vue';
|
||||
import {TASK_MODE_RANDOM} from '@/constants/task';
|
||||
import {Store} from 'vuex';
|
||||
import useForm from '@/components/form/form';
|
||||
import useSpiderService from '@/services/spider/spiderService';
|
||||
import {getDefaultFormComponentData} from '@/utils/form';
|
||||
import {
|
||||
FORM_FIELD_TYPE_INPUT,
|
||||
FORM_FIELD_TYPE_INPUT_TEXTAREA,
|
||||
FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
FORM_FIELD_TYPE_SELECT
|
||||
} from '@/constants/form';
|
||||
import useProject from '@/components/project/project';
|
||||
import useRequest from '@/services/request';
|
||||
import {FILTER_OP_CONTAINS} from '@/constants/filter';
|
||||
import {getModeOptions} from '@/utils/task';
|
||||
|
||||
const {
|
||||
getList,
|
||||
} = useRequest();
|
||||
|
||||
// get new spider
|
||||
export const getNewSpider = (): Spider => {
|
||||
return {
|
||||
mode: TASK_MODE_RANDOM,
|
||||
};
|
||||
};
|
||||
|
||||
// form component data
|
||||
const formComponentData = getDefaultFormComponentData<Spider>(getNewSpider);
|
||||
|
||||
const useSpider = (store: Store<RootStoreState>) => {
|
||||
// options for default mode
|
||||
const modeOptions = getModeOptions();
|
||||
|
||||
// use project
|
||||
const {
|
||||
allProjectSelectOptions,
|
||||
} = useProject(store);
|
||||
|
||||
// batch form fields
|
||||
const batchFormFields = computed<FormTableField[]>(() => [
|
||||
{
|
||||
prop: 'name',
|
||||
label: 'Name',
|
||||
width: '150',
|
||||
placeholder: 'Spider Name',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'cmd',
|
||||
label: 'Execute Command',
|
||||
width: '200',
|
||||
placeholder: 'Execute Command',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'param',
|
||||
label: 'Param',
|
||||
width: '200',
|
||||
placeholder: 'Param',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
|
||||
},
|
||||
{
|
||||
prop: 'mode',
|
||||
label: 'Default Run Mode',
|
||||
width: '200',
|
||||
fieldType: FORM_FIELD_TYPE_SELECT,
|
||||
options: modeOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
prop: 'project_id',
|
||||
label: 'Project',
|
||||
width: '200',
|
||||
fieldType: FORM_FIELD_TYPE_SELECT,
|
||||
options: allProjectSelectOptions.value,
|
||||
},
|
||||
{
|
||||
prop: 'description',
|
||||
label: 'Description',
|
||||
width: '200',
|
||||
fieldType: FORM_FIELD_TYPE_INPUT_TEXTAREA,
|
||||
},
|
||||
]);
|
||||
|
||||
// route
|
||||
const route = useRoute();
|
||||
|
||||
// spider id
|
||||
const id = computed(() => route.params.id);
|
||||
|
||||
// fetch data collections
|
||||
const fetchDataCollection = async (query: string) => {
|
||||
const conditions = [{
|
||||
key: 'name',
|
||||
op: FILTER_OP_CONTAINS,
|
||||
value: query,
|
||||
}] as FilterConditionData[];
|
||||
const res = await getList(`/data/collections`, {conditions});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// fetch data collection suggestions
|
||||
const fetchDataCollectionSuggestions = (query: string, cb: Function) => {
|
||||
fetchDataCollection(query)
|
||||
.then(data => {
|
||||
cb(data?.map((d: DataCollection) => {
|
||||
return {
|
||||
_id: d._id,
|
||||
value: d.name,
|
||||
};
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...useForm('spider', store, useSpiderService(store), formComponentData),
|
||||
batchFormFields,
|
||||
id,
|
||||
modeOptions,
|
||||
fetchDataCollection,
|
||||
fetchDataCollectionSuggestions,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSpider;
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<el-card class="metric-card">
|
||||
<el-col :span="6" class="icon-col">
|
||||
<i :class="icon" :style="{color:color}" />
|
||||
</el-col>
|
||||
<el-col :span="18" class="text-col">
|
||||
<el-row>
|
||||
<label class="label">{{ $t(label) }}</label>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="value">{{ value }}</div>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MetricCard',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
color() {
|
||||
if (this.type === 'primary') {
|
||||
return '#409EFF'
|
||||
} else if (this.type === 'warning') {
|
||||
return '#e6a23c'
|
||||
} else if (this.type === 'success') {
|
||||
return '#67c23a'
|
||||
} else if (this.type === 'danger') {
|
||||
return '#f56c6c'
|
||||
} else {
|
||||
return 'grey'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.metric-card:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.metric-card .icon-col i {
|
||||
margin-bottom: 15px;
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.metric-card .text-col {
|
||||
padding-left: 10px;
|
||||
height: 76px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-card .text-col label {
|
||||
font-size: 16px;
|
||||
display: block;
|
||||
height: 24px;
|
||||
color: grey;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric-card .text-col .value {
|
||||
font-size: 24px;
|
||||
display: block;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="spider-stats">
|
||||
<!--overall stats-->
|
||||
<el-row>
|
||||
<div class="metric-list">
|
||||
<metric-card
|
||||
label="30-Day Tasks"
|
||||
icon="fa fa-play"
|
||||
:value="overviewStats.task_count"
|
||||
type="danger"
|
||||
/>
|
||||
<metric-card
|
||||
label="30-Day Results"
|
||||
icon="fa fa-table"
|
||||
:value="overviewStats.result_count"
|
||||
type="primary"
|
||||
/>
|
||||
<metric-card
|
||||
label="Success Rate"
|
||||
icon="fa fa-check"
|
||||
:value="getPercentStr(overviewStats.success_rate)"
|
||||
type="success"
|
||||
/>
|
||||
<metric-card
|
||||
label="Avg Duration (sec)"
|
||||
icon="fa fa-hourglass"
|
||||
:value="getDecimal(overviewStats.avg_runtime_duration)"
|
||||
type="warning"
|
||||
/>
|
||||
</div>
|
||||
</el-row>
|
||||
<!--./overall stats-->
|
||||
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="chart-wrapper">
|
||||
<h4>{{ $t('Daily Tasks') }}</h4>
|
||||
<div id="task-line" class="chart" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="chart-wrapper">
|
||||
<h4>{{ $t('Daily Avg Duration (sec)') }}</h4>
|
||||
<div id="duration-line" class="chart" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import MetricCard from './MetricCard'
|
||||
import echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'SpiderStats',
|
||||
components: { MetricCard },
|
||||
data() {
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'overviewStats',
|
||||
'statusStats',
|
||||
'nodeStats',
|
||||
'dailyStats'
|
||||
])
|
||||
},
|
||||
|
||||
mounted() {
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderTaskLine() {
|
||||
const chart = echarts.init(this.$el.querySelector('#task-line'))
|
||||
const option = {
|
||||
grid: {
|
||||
top: 20,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: this.dailyStats.map(d => d.date)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: this.dailyStats.map(d => d.task_count),
|
||||
areaStyle: {},
|
||||
smooth: true
|
||||
}],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
show: true
|
||||
}
|
||||
}
|
||||
chart.setOption(option)
|
||||
},
|
||||
|
||||
renderDurationLine() {
|
||||
const chart = echarts.init(this.$el.querySelector('#duration-line'))
|
||||
const option = {
|
||||
grid: {
|
||||
top: 20,
|
||||
bottom: 40
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: this.dailyStats.map(d => d.date)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: this.dailyStats.map(d => d.avg_runtime_duration),
|
||||
areaStyle: {},
|
||||
smooth: true
|
||||
}],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
show: true
|
||||
}
|
||||
}
|
||||
chart.setOption(option)
|
||||
},
|
||||
|
||||
render() {
|
||||
this.renderTaskLine()
|
||||
this.renderDurationLine()
|
||||
},
|
||||
|
||||
update() {
|
||||
this.loading = true
|
||||
this.$store.dispatch('spider/getSpiderStats')
|
||||
.then(() => {
|
||||
this.render()
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error(this.$t('An error happened when fetching the data'))
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
getPercentStr(value) {
|
||||
if (value === undefined) return 'NA'
|
||||
return (value * 100).toFixed(2) + '%'
|
||||
},
|
||||
|
||||
getDecimal(value) {
|
||||
if (value === undefined) return 'NA'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metric-list {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.metric-list .metric-card {
|
||||
flex-basis: 25%;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.table {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display: inline-block;
|
||||
margin: 0
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<div class="legend">
|
||||
<el-tag type="primary" size="small">
|
||||
<i class="el-icon-loading" />
|
||||
{{ $t('Pending') }}
|
||||
</el-tag>
|
||||
<el-tag type="warning" size="small">
|
||||
<i class="el-icon-loading" />
|
||||
{{ $t('Running') }}
|
||||
</el-tag>
|
||||
<el-tag type="success" size="small">
|
||||
<i class="el-icon-check" />
|
||||
{{ $t('Finished') }}
|
||||
</el-tag>
|
||||
<el-tag type="danger" size="small">
|
||||
<i class="el-icon-error" />
|
||||
{{ $t('Error') }}
|
||||
</el-tag>
|
||||
<el-tag type="info" size="small">
|
||||
<i class="el-icon-video-pause" />
|
||||
{{ $t('Cancelled') }}
|
||||
</el-tag>
|
||||
<el-tag type="danger" size="small">
|
||||
<i class="el-icon-warning" />
|
||||
{{ $t('Abnormal') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StatusLegend'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.legend .el-tag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<el-tag :type="type" class="status-tag">
|
||||
<i :class="icon" />
|
||||
{{ $t(label) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StatusTag',
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
statusDict: {
|
||||
pending: { label: 'Pending', type: 'primary' },
|
||||
running: { label: 'Running', type: 'warning' },
|
||||
finished: { label: 'Finished', type: 'success' },
|
||||
error: { label: 'Error', type: 'danger' },
|
||||
cancelled: { label: 'Cancelled', type: 'info' },
|
||||
abnormal: { label: 'Abnormal', type: 'danger' }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
type() {
|
||||
const s = this.statusDict[this.status]
|
||||
if (s) {
|
||||
return s.type
|
||||
}
|
||||
return ''
|
||||
},
|
||||
label() {
|
||||
const s = this.statusDict[this.status]
|
||||
if (s) {
|
||||
return s.label
|
||||
}
|
||||
return 'NA'
|
||||
},
|
||||
icon() {
|
||||
if (this.status === 'finished') {
|
||||
return 'el-icon-check'
|
||||
} else if (this.status === 'pending') {
|
||||
return 'el-icon-loading'
|
||||
} else if (this.status === 'running') {
|
||||
return 'el-icon-loading'
|
||||
} else if (this.status === 'error') {
|
||||
return 'el-icon-error'
|
||||
} else if (this.status === 'cancelled') {
|
||||
return 'el-icon-video-pause'
|
||||
} else if (this.status === 'abnormal') {
|
||||
return 'el-icon-warning'
|
||||
} else {
|
||||
return 'el-icon-question'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div :style="{height:height+'px',zIndex:zIndex}">
|
||||
<div :class="className" :style="{top:stickyTop+'px',zIndex:zIndex,position:position,width:width,height:height+'px'}">
|
||||
<slot>
|
||||
<div>sticky</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Sticky',
|
||||
props: {
|
||||
stickyTop: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
active: false,
|
||||
position: '',
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
isSticky: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.height = this.$el.getBoundingClientRect().height
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
window.addEventListener('resize', this.handleReize)
|
||||
},
|
||||
activated() {
|
||||
this.handleScroll()
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
window.removeEventListener('resize', this.handleReize)
|
||||
},
|
||||
methods: {
|
||||
sticky() {
|
||||
if (this.active) {
|
||||
return
|
||||
}
|
||||
this.position = 'fixed'
|
||||
this.active = true
|
||||
this.width = this.width + 'px'
|
||||
this.isSticky = true
|
||||
},
|
||||
handleReset() {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
this.reset()
|
||||
},
|
||||
reset() {
|
||||
this.position = ''
|
||||
this.width = 'auto'
|
||||
this.active = false
|
||||
this.isSticky = false
|
||||
},
|
||||
handleScroll() {
|
||||
const width = this.$el.getBoundingClientRect().width
|
||||
this.width = width || 'auto'
|
||||
const offsetTop = this.$el.getBoundingClientRect().top
|
||||
if (offsetTop < this.stickyTop) {
|
||||
this.sticky()
|
||||
return
|
||||
}
|
||||
this.handleReset()
|
||||
},
|
||||
handleReize() {
|
||||
if (this.isSticky) {
|
||||
this.width = this.$el.getBoundingClientRect().width + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<svg :class="svgClass" aria-hidden="true" v-on="$listeners">
|
||||
<use :xlink:href="iconName" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SvgIcon',
|
||||
props: {
|
||||
iconClass: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
iconName() {
|
||||
return `#icon-${this.iconClass}`
|
||||
},
|
||||
svgClass() {
|
||||
if (this.className) {
|
||||
return 'svg-icon ' + this.className
|
||||
} else {
|
||||
return 'svg-icon'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.svg-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="deploy-table-view">
|
||||
<el-row class="title-wrapper">
|
||||
<h5 class="title">{{ title }}</h5>
|
||||
<el-button type="success" plain class="small-btn" size="mini" icon="fa fa-refresh" @click="onRefresh" />
|
||||
</el-row>
|
||||
<el-table border height="240px" :data="deployList">
|
||||
<el-table-column property="version" label="Ver" width="40" align="center" />
|
||||
<el-table-column property="node" label="Node" width="220" align="center">
|
||||
<template slot-scope="scope">
|
||||
<a class="a-tag" @click="onClickNode(scope.row)">{{ scope.row.node_id }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="spider_name" label="Spider" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<a class="a-tag" @click="onClickSpider(scope.row)">{{ scope.row.spider_name }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="finish_ts" label="Finish Time" width="auto" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'DeployTableView',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('deploy', [
|
||||
'deployList'
|
||||
])
|
||||
},
|
||||
methods: {
|
||||
onClickSpider(row) {
|
||||
this.$router.push(`/spiders/${row.spider_id}`)
|
||||
},
|
||||
onClickNode(row) {
|
||||
this.$router.push(`/nodes/${row.node_id}`)
|
||||
},
|
||||
onRefresh() {
|
||||
this.$store.dispatch('deploy/getDeployList', this.spiderForm._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-table .a-tag {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
float: left;
|
||||
margin: 10px 0 3px 0;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
float: right;
|
||||
width: 24px;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,360 +0,0 @@
|
||||
<template>
|
||||
<div class="fields-table-view">
|
||||
<el-row>
|
||||
<el-table
|
||||
:data="fields"
|
||||
class="table edit"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
:cell-style="getCellClassStyle"
|
||||
>
|
||||
<el-table-column class-name="action" width="80px" align="right">
|
||||
<template slot-scope="scope">
|
||||
<i class="action-item el-icon-copy-document" @click="onCopyField(scope.row)" />
|
||||
<i class="action-item el-icon-remove-outline" @click="onRemoveField(scope.row)" />
|
||||
<i class="action-item el-icon-circle-plus-outline" @click="onAddField(scope.row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Field Name')" width="150px">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-model="scope.row.name"
|
||||
:placeholder="$t('Field Name')"
|
||||
suffix-icon="el-icon-edit"
|
||||
@change="onNameChange(scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Selector Type')" width="150px" align="center" class-name="selector-type">
|
||||
<template slot-scope="scope">
|
||||
<span class="button-selector-item" @click="onClickSelectorType(scope.row, 'css')">
|
||||
<el-tag
|
||||
:class="scope.row.css ? 'active' : 'inactive'"
|
||||
type="success"
|
||||
>
|
||||
CSS
|
||||
</el-tag>
|
||||
</span>
|
||||
<span class="button-selector-item" @click="onClickSelectorType(scope.row, 'xpath')">
|
||||
<el-tag
|
||||
:class="scope.row.xpath ? 'active' : 'inactive'"
|
||||
type="primary"
|
||||
>
|
||||
XPath
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Selector')" width="200px">
|
||||
<template slot-scope="scope">
|
||||
<template v-if="scope.row.css">
|
||||
<el-input
|
||||
v-model="scope.row.css"
|
||||
:placeholder="$t('CSS / XPath')"
|
||||
suffix-icon="el-icon-edit"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-model="scope.row.xpath"
|
||||
:placeholder="$t('CSS / XPath')"
|
||||
suffix-icon="el-icon-edit"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Is Attribute')" width="150px" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="button-selector-item" @click="onClickIsAttribute(scope.row, false)">
|
||||
<el-tag
|
||||
:class="!isShowAttr(scope.row) ? 'active' : 'inactive'"
|
||||
type="success"
|
||||
>
|
||||
{{ $t('Text') }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<span class="button-selector-item" @click="onClickIsAttribute(scope.row, true)">
|
||||
<el-tag
|
||||
:class="isShowAttr(scope.row) ? 'active' : 'inactive'"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('Attribute') }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Attribute')" width="200px">
|
||||
<template slot-scope="scope">
|
||||
<template v-if="isShowAttr(scope.row)">
|
||||
<el-input
|
||||
v-model="scope.row.attr"
|
||||
:placeholder="$t('Attribute')"
|
||||
suffix-icon="el-icon-edit"
|
||||
@change="onAttrChange(scope.row)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="margin-left: 15px; color: lightgrey">
|
||||
N/A
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Next Stage')" width="250px">
|
||||
<template slot-scope="scope">
|
||||
<el-select
|
||||
v-model="scope.row.next_stage"
|
||||
:class="!scope.row.next_stage ? 'disabled' : ''"
|
||||
@change="onChangeNextStage(scope.row)"
|
||||
>
|
||||
<el-option :label="$t('No Next Stage')" value="" />
|
||||
<el-option v-for="n in filteredStageNames" :key="n" :label="n" :value="n" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Remark')" width="auto" min-width="120px">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.remark" :placeholder="$t('Remark')" suffix-icon="el-icon-edit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'FieldsTableView',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'list'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
stage: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
stageNames: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
filteredStageNames() {
|
||||
return this.stageNames.filter(n => n !== this.stage.name)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onNameChange(row) {
|
||||
if (this.fields.filter(d => d.name === row.name).length > 1) {
|
||||
this.$message.error(this.$t(`Duplicated field names for ${row.name}`))
|
||||
}
|
||||
this.$st.sendEv('爬虫详情', '配置', '更改字段')
|
||||
},
|
||||
onClickSelectorType(row, selectorType) {
|
||||
this.$st.sendEv('爬虫详情', '配置', `点击字段选择器类别-${selectorType}`)
|
||||
if (selectorType === 'css') {
|
||||
if (row.xpath) this.$set(row, 'xpath', '')
|
||||
if (!row.css) this.$set(row, 'css', 'body')
|
||||
} else {
|
||||
if (row.css) this.$set(row, 'css', '')
|
||||
if (!row.xpath) this.$set(row, 'xpath', '//body')
|
||||
}
|
||||
},
|
||||
onClickIsAttribute(row, isAttribute) {
|
||||
this.$st.sendEv('爬虫详情', '配置', '设置字段属性')
|
||||
if (!isAttribute) {
|
||||
// 文本
|
||||
if (row.attr) this.$set(row, 'attr', '')
|
||||
} else {
|
||||
// 属性
|
||||
if (!row.attr) this.$set(row, 'attr', 'href')
|
||||
}
|
||||
this.$set(row, 'isAttrChange', false)
|
||||
},
|
||||
onCopyField(row) {
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
if (row.name === this.fields[i].name) {
|
||||
this.fields.splice(i, 0, JSON.parse(JSON.stringify(row)))
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveField(row) {
|
||||
this.$st.sendEv('爬虫详情', '配置', '删除字段')
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
if (row.name === this.fields[i].name) {
|
||||
this.fields.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.fields.length === 0) {
|
||||
this.fields.push({
|
||||
xpath: '//body',
|
||||
next_stage: ''
|
||||
})
|
||||
}
|
||||
},
|
||||
onAddField(row) {
|
||||
this.$st.sendEv('爬虫详情', '配置', '添加字段')
|
||||
for (let i = 0; i < this.fields.length; i++) {
|
||||
if (row.name === this.fields[i].name) {
|
||||
this.fields.splice(i + 1, 0, {
|
||||
name: `field_${Math.floor(new Date().getTime()).toString()}`,
|
||||
xpath: '//body',
|
||||
next_stage: ''
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
getCellClassStyle({ row, columnIndex }) {
|
||||
if (columnIndex === 1) {
|
||||
// 字段名称
|
||||
if (!row.name) {
|
||||
return {
|
||||
'border': '1px solid red'
|
||||
}
|
||||
}
|
||||
} else if (columnIndex === 3) {
|
||||
// 选择器
|
||||
if (!row.css && !row.xpath) {
|
||||
return {
|
||||
'border': '1px solid red'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onChangeNextStage(row) {
|
||||
this.fields.forEach(f => {
|
||||
if (f.name !== row.name) {
|
||||
this.$set(f, 'next_stage', '')
|
||||
}
|
||||
})
|
||||
},
|
||||
onAttrChange(row) {
|
||||
this.$set(row, 'isAttrChange', !row.attr)
|
||||
},
|
||||
isShowAttr(row) {
|
||||
return (row.attr || row.isAttrChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-table.edit >>> .el-table__body td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-table__body td .cell {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-input__inner:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-input__inner {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-select .el-input .el-select__caret {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .button-selector-item {
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-tag.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action {
|
||||
background: none !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr th {
|
||||
border-right: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr td:nth-child(2) {
|
||||
border-left: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr td {
|
||||
border-right: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit::before {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action-item {
|
||||
font-size: 14px;
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action-item:last-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.button-group-container {
|
||||
/*display: inline-block;*/
|
||||
/*width: 100%;*/
|
||||
}
|
||||
|
||||
.button-group-container .title {
|
||||
float: left;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.button-group-container .button-group {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.action-button-group {
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-button-group >>> .el-checkbox__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-select.disabled .el-input__inner {
|
||||
color: lightgrey;
|
||||
}
|
||||
</style>
|
||||
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div class="general-table-view">
|
||||
<el-table
|
||||
:data="filteredData"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
border
|
||||
>
|
||||
<template v-for="col in columns">
|
||||
<el-table-column :key="col" :label="col" :property="col" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-popover trigger="hover" :content="getString(scope.row[col])" popper-class="cell-popover">
|
||||
<div v-if="isUrl(scope.row[col])" slot="reference" class="wrapper">
|
||||
<a :href="getString(scope.row[col])" target="_blank" style="color: #409eff">
|
||||
{{ getString(scope.row[col]) }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-else slot="reference" class="wrapper">
|
||||
{{ getString(scope.row[col]) }}
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
:current-page.sync="pageNum"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:page-size.sync="pageSize"
|
||||
layout="sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'GeneralTableView',
|
||||
props: {
|
||||
pageNum: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
filteredData() {
|
||||
return this.data
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isUrl(value) {
|
||||
if (!value) return false
|
||||
if (!value.match) return false
|
||||
return !!value.match(/^https?:\/\//)
|
||||
},
|
||||
onPageChange() {
|
||||
this.$emit('page-change', { pageNum: this.pageNum, pageSize: this.pageSize })
|
||||
},
|
||||
getString(value) {
|
||||
if (value === undefined) return ''
|
||||
const str = JSON.stringify(value)
|
||||
if (str.match(/^"(.*)"$/)) return str.match(/^"(.*)"$/)[1]
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.general-table-view >>> .cell .wrapper:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.general-table-view >>> .cell .wrapper {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.cell-popover {
|
||||
max-width: 480px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,248 +0,0 @@
|
||||
<template>
|
||||
<div class="setting-list-table-view">
|
||||
<el-row>
|
||||
<el-table
|
||||
:data="list"
|
||||
class="table edit"
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
:cell-style="getCellClassStyle"
|
||||
>
|
||||
<el-table-column class-name="action" width="80px" align="right">
|
||||
<template slot-scope="scope">
|
||||
<!-- <i class="action-item el-icon-copy-document" @click="onCopyField(scope.row)"></i>-->
|
||||
<i class="action-item el-icon-remove-outline" @click="onRemoveField(scope.row)" />
|
||||
<i class="action-item el-icon-circle-plus-outline" @click="onAddField(scope.row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Name')" width="240px">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-model="scope.row.name"
|
||||
:placeholder="$t('Name')"
|
||||
suffix-icon="el-icon-edit"
|
||||
@change="onChange(scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('Value')" width="auto" min-width="120px">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-model="scope.row.value"
|
||||
:placeholder="$t('Value')"
|
||||
suffix-icon="el-icon-edit"
|
||||
@change="onChange(scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'SettingFieldsTableView',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'list'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
stageNames: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
list() {
|
||||
const list = []
|
||||
for (const name in this.spiderForm.config.settings) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.spiderForm.config.settings, name)) {
|
||||
const value = this.spiderForm.config.settings[name]
|
||||
list.push({ name, value })
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.list.length === 0) {
|
||||
this.$store.commit(
|
||||
'spider/SET_SPIDER_FORM_CONFIG_SETTING_ITEM',
|
||||
'VARIABLE_NAME_' + Math.floor(new Date().getTime()),
|
||||
'VARIABLE_VALUE_' + Math.floor(new Date().getTime())
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChange(row) {
|
||||
if (this.list.filter(d => d.name === row.name).length > 1) {
|
||||
this.$message.error(this.$t(`Duplicated field names for ${row.name}`))
|
||||
}
|
||||
this.$store.commit('spider/SET_SPIDER_FORM_CONFIG_SETTINGS', this.list)
|
||||
},
|
||||
onRemoveField(row) {
|
||||
this.$st.sendEv('爬虫详情', '配置', '删除设置')
|
||||
const list = JSON.parse(JSON.stringify(this.list))
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (row.name === list[i].name) {
|
||||
list.splice(i, 1)
|
||||
}
|
||||
}
|
||||
if (list.length === 0) {
|
||||
list.push({
|
||||
name: `VARIABLE_NAME_${Math.floor(new Date().getTime())}`,
|
||||
value: `VARIABLE_VALUE_${Math.floor(new Date().getTime())}`
|
||||
})
|
||||
}
|
||||
this.$store.commit('spider/SET_SPIDER_FORM_CONFIG_SETTINGS', list)
|
||||
},
|
||||
onAddField(row) {
|
||||
this.$st.sendEv('爬虫详情', '配置', '添加设置')
|
||||
const list = JSON.parse(JSON.stringify(this.list))
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (row.name === list[i].name) {
|
||||
const name = 'VARIABLE_NAME_' + Math.floor(new Date().getTime())
|
||||
const value = 'VARIABLE_VALUE_' + Math.floor(new Date().getTime())
|
||||
list.push({ name, value })
|
||||
break
|
||||
}
|
||||
}
|
||||
this.$store.commit('spider/SET_SPIDER_FORM_CONFIG_SETTINGS', list)
|
||||
},
|
||||
getCellClassStyle({ row, columnIndex }) {
|
||||
if (columnIndex === 1) {
|
||||
// 字段名称
|
||||
if (!row.name) {
|
||||
return {
|
||||
'border': '1px solid red'
|
||||
}
|
||||
}
|
||||
} else if (columnIndex === 3) {
|
||||
// 选择器
|
||||
if (!row.css && !row.xpath) {
|
||||
return {
|
||||
'border': '1px solid red'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onChangeNextStage(row) {
|
||||
this.list.forEach(f => {
|
||||
if (f.name !== row.name) {
|
||||
this.$set(f, 'next_stage', '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-table.edit >>> .el-table__body td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-table__body td .cell {
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-input__inner:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-input__inner {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-select .el-input .el-select__caret {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .button-selector-item {
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-tag.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action {
|
||||
background: none !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr th {
|
||||
border-right: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr td:nth-child(2) {
|
||||
border-left: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit >>> tr td {
|
||||
border-right: 1px solid rgb(220, 223, 230);
|
||||
}
|
||||
|
||||
.el-table.edit::before {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action-item {
|
||||
font-size: 14px;
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .action-item:last-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.button-group-container {
|
||||
/*display: inline-block;*/
|
||||
/*width: 100%;*/
|
||||
}
|
||||
|
||||
.button-group-container .title {
|
||||
float: left;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.button-group-container .button-group {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.action-button-group {
|
||||
display: flex;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-button-group >>> .el-checkbox__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-table.edit >>> .el-select.disabled .el-input__inner {
|
||||
color: lightgrey;
|
||||
}
|
||||
</style>
|
||||
@@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<div class="task-table-view">
|
||||
<el-row class="title-wrapper">
|
||||
<h5 class="title">{{ title }}</h5>
|
||||
<el-button type="success" plain class="small-btn" size="mini" icon="fa fa-refresh" @click="onRefresh" />
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="taskList"
|
||||
border
|
||||
:header-cell-style="{background:'rgb(48, 65, 86)',color:'white'}"
|
||||
height="480px"
|
||||
@row-click="onClickTask"
|
||||
>
|
||||
<el-table-column property="node" :label="$t('Node')" width="120" align="left">
|
||||
<template slot-scope="scope">
|
||||
<a class="a-tag" @click="onClickNode(scope.row)">{{ scope.row.node_name }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="spider_name" :label="$t('Spider')" width="120" align="left">
|
||||
<template slot-scope="scope">
|
||||
<a class="a-tag" @click="onClickSpider(scope.row)">{{ scope.row.spider_name }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="param" :label="$t('Parameters')" width="120">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.param }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="result_count" :label="$t('Results Count')" width="60" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.result_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="$t('Status')"
|
||||
align="left"
|
||||
width="100"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<status-tag :status="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!--<el-table-column property="create_ts" label="Create Time" width="auto" align="center"></el-table-column>-->
|
||||
<el-table-column property="create_ts" :label="$t('Create Time')" width="150" align="left">
|
||||
<template slot-scope="scope">
|
||||
<a href="javascript:" class="a-tag" @click="onClickTask(scope.row)">
|
||||
{{ getTime(scope.row.create_ts).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
mapState
|
||||
} from 'vuex'
|
||||
import dayjs from 'dayjs'
|
||||
import StatusTag from '../Status/StatusTag'
|
||||
|
||||
export default {
|
||||
name: 'TaskTableView',
|
||||
components: { StatusTag },
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// setInterval handle
|
||||
handle: undefined,
|
||||
// 防抖
|
||||
clicked: false
|
||||
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('spider', [
|
||||
'spiderForm'
|
||||
]),
|
||||
...mapState('task', [
|
||||
'taskList'
|
||||
])
|
||||
},
|
||||
mounted() {
|
||||
this.handle = setInterval(() => {
|
||||
this.onRefresh()
|
||||
}, 5000)
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.handle)
|
||||
},
|
||||
methods: {
|
||||
onClickSpider(row) {
|
||||
this.clicked = true
|
||||
setTimeout(() => {
|
||||
this.clicked = false
|
||||
}, 100)
|
||||
this.$router.push(`/spiders/${row.spider_id}`)
|
||||
if (this.$route.path.match(/\/nodes\//)) {
|
||||
this.$st.sendEv('节点详情', '概览', '查看爬虫')
|
||||
}
|
||||
},
|
||||
onClickNode(row) {
|
||||
this.clicked = true
|
||||
setTimeout(() => {
|
||||
this.clicked = false
|
||||
}, 100)
|
||||
this.$router.push(`/nodes/${row.node_id}`)
|
||||
if (this.$route.path.match(/\/spiders\//)) {
|
||||
this.$st.sendEv('爬虫详情', '概览', '查看节点')
|
||||
}
|
||||
},
|
||||
onClickTask(row) {
|
||||
if (!this.clicked) {
|
||||
this.$router.push(`/tasks/${row._id}`)
|
||||
if (this.$route.path.match(/\/nodes\//)) {
|
||||
this.$st.sendEv('节点详情', '概览', '查看任务')
|
||||
} else if (this.$route.path.match(/\/spiders\//)) {
|
||||
this.$st.sendEv('爬虫详情', '概览', '查看任务')
|
||||
}
|
||||
}
|
||||
},
|
||||
onRefresh() {
|
||||
if (this.$route.path.split('/')[1] === 'spiders') {
|
||||
this.$store.dispatch('spider/getTaskList', this.$route.params.id)
|
||||
} else if (this.$route.path.split('/')[1] === 'nodes') {
|
||||
this.$store.dispatch('node/getTaskList', this.$route.params.id)
|
||||
}
|
||||
},
|
||||
getTime(str) {
|
||||
return dayjs(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-table-view {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-table .a-tag {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 10px 0 3px 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
float: right;
|
||||
width: 24px;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.el-table >>> tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<el-color-picker
|
||||
v-model="theme"
|
||||
class="theme-picker"
|
||||
popper-class="theme-picker-dropdown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
const version = require('element-ui/package.json').version // element-ui version from node_modules
|
||||
const ORIGINAL_THEME = '#409EFF' // default color
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
chalk: '', // content of theme-chalk css
|
||||
theme: ORIGINAL_THEME
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
theme(val) {
|
||||
const oldVal = this.theme
|
||||
if (typeof val !== 'string') return
|
||||
const themeCluster = this.getThemeCluster(val.replace('#', ''))
|
||||
const originalCluster = this.getThemeCluster(oldVal.replace('#', ''))
|
||||
console.log(themeCluster, originalCluster)
|
||||
const getHandler = (variable, id) => {
|
||||
return () => {
|
||||
const originalCluster = this.getThemeCluster(ORIGINAL_THEME.replace('#', ''))
|
||||
const newStyle = this.updateStyle(this[variable], originalCluster, themeCluster)
|
||||
|
||||
let styleTag = document.getElementById(id)
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.setAttribute('id', id)
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.innerText = newStyle
|
||||
}
|
||||
}
|
||||
|
||||
const chalkHandler = getHandler('chalk', 'chalk-style')
|
||||
|
||||
if (!this.chalk) {
|
||||
const url = `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`
|
||||
this.getCSSString(url, chalkHandler, 'chalk')
|
||||
} else {
|
||||
chalkHandler()
|
||||
}
|
||||
|
||||
const styles = [].slice.call(document.querySelectorAll('style'))
|
||||
.filter(style => {
|
||||
const text = style.innerText
|
||||
return new RegExp(oldVal, 'i').test(text) && !/Chalk Variables/.test(text)
|
||||
})
|
||||
styles.forEach(style => {
|
||||
const { innerText } = style
|
||||
if (typeof innerText !== 'string') return
|
||||
style.innerText = this.updateStyle(innerText, originalCluster, themeCluster)
|
||||
})
|
||||
this.$message({
|
||||
message: '换肤成功',
|
||||
type: 'success'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateStyle(style, oldCluster, newCluster) {
|
||||
let newStyle = style
|
||||
oldCluster.forEach((color, index) => {
|
||||
newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
|
||||
})
|
||||
return newStyle
|
||||
},
|
||||
|
||||
getCSSString(url, callback, variable) {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
this[variable] = xhr.responseText.replace(/@font-face{[^}]+}/, '')
|
||||
callback()
|
||||
}
|
||||
}
|
||||
xhr.open('GET', url)
|
||||
xhr.send()
|
||||
},
|
||||
|
||||
getThemeCluster(theme) {
|
||||
const tintColor = (color, tint) => {
|
||||
let red = parseInt(color.slice(0, 2), 16)
|
||||
let green = parseInt(color.slice(2, 4), 16)
|
||||
let blue = parseInt(color.slice(4, 6), 16)
|
||||
|
||||
if (tint === 0) { // when primary color is in its rgb space
|
||||
return [red, green, blue].join(',')
|
||||
} else {
|
||||
red += Math.round(tint * (255 - red))
|
||||
green += Math.round(tint * (255 - green))
|
||||
blue += Math.round(tint * (255 - blue))
|
||||
|
||||
red = red.toString(16)
|
||||
green = green.toString(16)
|
||||
blue = blue.toString(16)
|
||||
|
||||
return `#${red}${green}${blue}`
|
||||
}
|
||||
}
|
||||
|
||||
const shadeColor = (color, shade) => {
|
||||
let red = parseInt(color.slice(0, 2), 16)
|
||||
let green = parseInt(color.slice(2, 4), 16)
|
||||
let blue = parseInt(color.slice(4, 6), 16)
|
||||
|
||||
red = Math.round((1 - shade) * red)
|
||||
green = Math.round((1 - shade) * green)
|
||||
blue = Math.round((1 - shade) * blue)
|
||||
|
||||
red = red.toString(16)
|
||||
green = green.toString(16)
|
||||
blue = blue.toString(16)
|
||||
|
||||
return `#${red}${green}${blue}`
|
||||
}
|
||||
|
||||
const clusters = [theme]
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
clusters.push(tintColor(theme, Number((i / 10).toFixed(2))))
|
||||
}
|
||||
clusters.push(shadeColor(theme, 0.1))
|
||||
return clusters
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.theme-picker .el-color-picker__trigger {
|
||||
margin-top: 12px;
|
||||
height: 26px!important;
|
||||
width: 26px!important;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.theme-picker-dropdown .el-color-dropdown__link-btn {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<el-button :style="{background:color,borderColor:color}" icon="el-icon-upload" size="mini" type="primary" @click=" dialogVisible=true">上传图片
|
||||
</el-button>
|
||||
<el-dialog :visible.sync="dialogVisible">
|
||||
<el-upload
|
||||
:multiple="true"
|
||||
:file-list="fileList"
|
||||
:show-file-list="true"
|
||||
:on-remove="handleRemove"
|
||||
:on-success="handleSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
class="editor-slide-upload"
|
||||
action="https://httpbin.org/post"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<el-button size="small" type="primary">点击上传</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import { getToken } from 'api/qiniu'
|
||||
|
||||
export default {
|
||||
name: 'EditorSlideUpload',
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
default: '#1890ff'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: false,
|
||||
listObj: {},
|
||||
fileList: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkAllSuccess() {
|
||||
return Object.keys(this.listObj).every(item => this.listObj[item].hasSuccess)
|
||||
},
|
||||
handleSubmit() {
|
||||
const arr = Object.keys(this.listObj).map(v => this.listObj[v])
|
||||
if (!this.checkAllSuccess()) {
|
||||
this.$message('请等待所有图片上传成功 或 出现了网络问题,请刷新页面重新上传!')
|
||||
return
|
||||
}
|
||||
this.$emit('successCBK', arr)
|
||||
this.listObj = {}
|
||||
this.fileList = []
|
||||
this.dialogVisible = false
|
||||
},
|
||||
handleSuccess(response, file) {
|
||||
const uid = file.uid
|
||||
const objKeyArr = Object.keys(this.listObj)
|
||||
for (let i = 0, len = objKeyArr.length; i < len; i++) {
|
||||
if (this.listObj[objKeyArr[i]].uid === uid) {
|
||||
this.listObj[objKeyArr[i]].url = response.files.file
|
||||
this.listObj[objKeyArr[i]].hasSuccess = true
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
handleRemove(file) {
|
||||
const uid = file.uid
|
||||
const objKeyArr = Object.keys(this.listObj)
|
||||
for (let i = 0, len = objKeyArr.length; i < len; i++) {
|
||||
if (this.listObj[objKeyArr[i]].uid === uid) {
|
||||
delete this.listObj[objKeyArr[i]]
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeUpload(file) {
|
||||
const _self = this
|
||||
const _URL = window.URL || window.webkitURL
|
||||
const fileName = file.uid
|
||||
this.listObj[fileName] = {}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.src = _URL.createObjectURL(file)
|
||||
img.onload = function() {
|
||||
_self.listObj[fileName] = { hasSuccess: false, uid: file.uid, width: this.width, height: this.height }
|
||||
}
|
||||
resolve(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss" scoped>
|
||||
.editor-slide-upload {
|
||||
margin-bottom: 20px;
|
||||
/deep/ .el-upload--picture-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<el-button :style="{background:color,borderColor:color}" icon="el-icon-upload" size="mini" type="primary" @click=" dialogVisible=true">上传图片
|
||||
</el-button>
|
||||
<el-dialog :visible.sync="dialogVisible">
|
||||
<el-upload
|
||||
:multiple="true"
|
||||
:file-list="fileList"
|
||||
:show-file-list="true"
|
||||
:on-remove="handleRemove"
|
||||
:on-success="handleSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
class="editor-slide-upload"
|
||||
action="https://httpbin.org/post"
|
||||
list-type="picture-card"
|
||||
>
|
||||
<el-button size="small" type="primary">点击上传</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import { getToken } from 'api/qiniu'
|
||||
|
||||
export default {
|
||||
name: 'EditorSlideUpload',
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
default: '#1890ff'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogVisible: false,
|
||||
listObj: {},
|
||||
fileList: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkAllSuccess() {
|
||||
return Object.keys(this.listObj).every(item => this.listObj[item].hasSuccess)
|
||||
},
|
||||
handleSubmit() {
|
||||
const arr = Object.keys(this.listObj).map(v => this.listObj[v])
|
||||
if (!this.checkAllSuccess()) {
|
||||
this.$message('请等待所有图片上传成功 或 出现了网络问题,请刷新页面重新上传!')
|
||||
return
|
||||
}
|
||||
this.$emit('successCBK', arr)
|
||||
this.listObj = {}
|
||||
this.fileList = []
|
||||
this.dialogVisible = false
|
||||
},
|
||||
handleSuccess(response, file) {
|
||||
const uid = file.uid
|
||||
const objKeyArr = Object.keys(this.listObj)
|
||||
for (let i = 0, len = objKeyArr.length; i < len; i++) {
|
||||
if (this.listObj[objKeyArr[i]].uid === uid) {
|
||||
this.listObj[objKeyArr[i]].url = response.files.file
|
||||
this.listObj[objKeyArr[i]].hasSuccess = true
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
handleRemove(file) {
|
||||
const uid = file.uid
|
||||
const objKeyArr = Object.keys(this.listObj)
|
||||
for (let i = 0, len = objKeyArr.length; i < len; i++) {
|
||||
if (this.listObj[objKeyArr[i]].uid === uid) {
|
||||
delete this.listObj[objKeyArr[i]]
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeUpload(file) {
|
||||
const _self = this
|
||||
const _URL = window.URL || window.webkitURL
|
||||
const fileName = file.uid
|
||||
this.listObj[fileName] = {}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.src = _URL.createObjectURL(file)
|
||||
img.onload = function() {
|
||||
_self.listObj[fileName] = { hasSuccess: false, uid: file.uid, width: this.width, height: this.height }
|
||||
}
|
||||
resolve(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style rel="stylesheet/scss" lang="scss" scoped>
|
||||
.editor-slide-upload {
|
||||
margin-bottom: 20px;
|
||||
/deep/ .el-upload--picture-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||