updated frontend

This commit is contained in:
marvzhang
2021-07-15 21:37:37 +08:00
parent db6414aa5b
commit db7920ac69
550 changed files with 27134 additions and 49444 deletions

3
frontend/.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

@@ -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
View File

@@ -0,0 +1 @@
registry=https://registry.npm.taobao.org

View File

@@ -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

View File

@@ -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.

View File

@@ -1,5 +1,6 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
'@vue/cli-plugin-babel/preset',
'@babel/preset-typescript'
]
}

View File

@@ -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
View 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,
};

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

21097
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View 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()
}())

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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: '关闭'
}

View File

@@ -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'
}

View File

@@ -1,7 +0,0 @@
import en from './en'
import cn from './cn'
export default {
en,
cn
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>
<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>

View 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>

View 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>

View File

@@ -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>

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More