updated frontend

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

View File

@@ -0,0 +1,4 @@
export const isDuplicated = <T = any>(array?: T[]) => {
if (!array) return false;
return array.length > new Set(array).size;
};

View File

@@ -1,13 +0,0 @@
const TokenKey = 'token'
export function getToken() {
return window.localStorage.getItem(TokenKey)
}
export function setToken(token) {
return window.localStorage.setItem(TokenKey, token)
}
export function removeToken() {
return window.localStorage.removeItem(TokenKey)
}

View File

@@ -0,0 +1,9 @@
export const TOKEN_KEY = 'token';
export const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
export const setToken = (token: string) => {
return localStorage.setItem(TOKEN_KEY, token);
};

View File

@@ -0,0 +1,11 @@
import {onBeforeUnmount, onMounted} from 'vue';
export const setupAutoUpdate = (fn: Function, interval?: number, handle?: number) => {
if (!interval) interval = 5000;
onMounted(() => {
handle = setInterval(fn, interval);
});
onBeforeUnmount(() => {
clearInterval(handle);
});
};

View File

@@ -0,0 +1,309 @@
import CodeMirror, {Editor, EditorConfiguration} from 'codemirror';
// import addons
import 'codemirror/addon/search/search.js';
import 'codemirror/addon/search/searchcursor';
import 'codemirror/addon/search/matchesonscrollbar.js';
import 'codemirror/addon/search/matchesonscrollbar.css';
import 'codemirror/addon/search/match-highlighter';
import 'codemirror/addon/edit/matchtags';
import 'codemirror/addon/edit/matchbrackets';
import 'codemirror/addon/edit/closebrackets';
import 'codemirror/addon/edit/closetag';
import 'codemirror/addon/comment/comment';
import 'codemirror/addon/hint/show-hint';
// import keymap
import 'codemirror/keymap/emacs.js';
import 'codemirror/keymap/sublime.js';
import 'codemirror/keymap/vim.js';
const themes = [
'3024-day',
'3024-night',
'abcdef',
'ambiance',
'ambiance-mobile',
'ayu-dark',
'ayu-mirage',
'base16-dark',
'base16-light',
'bespin',
'blackboard',
'cobalt',
'colorforth',
'darcula',
'dracula',
'duotone-dark',
'duotone-light',
'eclipse',
'elegant',
'erlang-dark',
'gruvbox-dark',
'hopscotch',
'icecoder',
'idea',
'isotope',
'lesser-dark',
'liquibyte',
'lucario',
'material',
'material-darker',
'material-ocean',
'material-palenight',
'mbo',
'mdn-like',
'midnight',
'monokai',
'moxer',
'neat',
'neo',
'night',
'nord',
'oceanic-next',
'panda-syntax',
'paraiso-dark',
'paraiso-light',
'pastel-on-dark',
'railscasts',
'rubyblue',
'seti',
'shadowfox',
'solarized',
'ssms',
'the-matrix',
'tomorrow-night-bright',
'tomorrow-night-eighties',
'ttcn',
'twilight',
'vibrant-ink',
'xq-dark',
'xq-light',
'yeti',
'yonce',
'zenburn',
];
const template = `import os
def func(a):
pass
class Class1:
pass
`;
const optionsDefinitions: FileEditorOptionDefinition[] = [
{
name: 'theme',
title: 'Theme',
description: 'The theme to style the editor with.',
type: 'select',
data: {
options: themes,
},
},
{
name: 'indentUnit',
title: 'Indent Unit',
description: 'How many spaces a block (whatever that means in the edited language) should be indented.',
type: 'input-number',
data: {
min: 1,
}
},
{
name: 'smartIndent',
title: 'Smart Indent',
description: 'Whether to use the context-sensitive indentation that the mode provides (or just indent the same as the line before).',
type: 'switch',
},
{
name: 'tabSize',
title: 'Tab Size',
description: 'The width of a tab character. Defaults to 4.',
type: 'input-number',
data: {
min: 1,
}
},
{
name: 'indentWithTabs',
title: 'Indent with Tabs',
description: 'Whether, when indenting, the first N*tabSize spaces should be replaced by N tabs.',
type: 'switch',
},
{
name: 'electricChars',
title: 'Electric Chars',
description: 'Configures whether the editor should re-indent the current line when a character is typed that might change its proper indentation (only works if the mode supports indentation).',
type: 'switch',
},
{
name: 'keyMap',
title: 'Keymap',
description: 'Configures the keymap to use.',
type: 'select',
data: {
options: [
'default',
'emacs',
'sublime',
'vim',
]
},
},
{
name: 'lineWrapping',
title: 'Line Wrapping',
description: 'Whether to scroll or wrap for long lines.',
type: 'switch',
},
{
name: 'lineNumbers',
title: 'Line Numbers',
description: 'Whether to show line numbers to the left of the editor.',
type: 'switch',
},
{
name: 'showCursorWhenSelecting',
title: 'Show Cursor When Selecting',
description: 'Whether the cursor should be drawn when a selection is active.',
type: 'switch',
},
{
name: 'lineWiseCopyCut',
title: 'Line-wise Copy-Cut',
description: 'When enabled, doing copy or cut when there is no selection will copy or cut the whole lines that have cursors on them.',
type: 'switch',
},
{
name: 'pasteLinesPerSelection',
title: 'Paste Lines per Selection',
description: 'When pasting something from an external source (not from the editor itself), if the number of lines matches the number of selection, the editor will by default insert one line per selection. You can set this to false to disable that behavior.',
type: 'switch',
},
{
name: 'undoDepth',
title: 'Paste Lines per Selection',
description: 'The maximum number of undo levels that the editor stores.',
type: 'input-number',
data: {
min: 1,
},
},
{
name: 'cursorBlinkRate',
title: 'Cursor Blink Rate',
description: 'Half-period in milliseconds used for cursor blinking.',
type: 'input-number',
data: {
min: 10,
},
},
{
name: 'cursorScrollMargin',
title: 'Cursor Scroll Margin',
description: 'How much extra space to always keep above and below the cursor when approaching the top or bottom of the visible view in a scrollable document.',
type: 'input-number',
data: {
min: 0,
},
},
{
name: 'cursorHeight',
title: 'Cursor Height',
description: 'Determines the height of the cursor. Setting it to 1, means it spans the whole height of the line. For some fonts (and by some tastes) a smaller height (for example 0.85), which causes the cursor to not reach all the way to the bottom of the line, looks better',
type: 'input-number',
data: {
min: 0,
step: 0.01,
},
},
{
name: 'maxHighlightLength',
title: 'Max Highlight Length',
description: 'When highlighting long lines, in order to stay responsive, the editor will give up and simply style the rest of the line as plain text when it reaches a certain position.',
type: 'input-number',
data: {
min: 1,
},
},
{
name: 'spellcheck',
title: 'Spell Check',
description: 'Specifies whether or not spellcheck will be enabled on the input.',
type: 'switch',
},
{
name: 'autocorrect',
title: 'Auto Correct',
description: 'Specifies whether or not auto-correct will be enabled on the input.',
type: 'switch',
},
{
name: 'autocapitalize',
title: 'Auto Capitalize',
description: 'Specifies whether or not auto-capitalization will be enabled on the input.',
type: 'switch',
},
{
name: 'highlightSelectionMatches',
title: 'Highlight Selection Matches',
description: 'Adds a highlightSelectionMatches option that can be enabled to highlight all instances of a currently selected word. When enabled, it causes the current word to be highlighted when nothing is selected.',
type: 'switch',
},
{
name: 'matchBrackets',
title: 'Match Brackets',
description: 'When set to true or an options object, causes matching brackets to be highlighted whenever the cursor is next to them.',
type: 'switch',
},
{
name: 'matchTags',
title: 'Match Tags',
description: 'When enabled will cause the tags around the cursor to be highlighted',
type: 'switch',
},
{
name: 'autoCloseBrackets',
title: 'Auto-Close Brackets',
description: 'Will auto-close brackets and quotes when typed. It\'ll auto-close ()[]{}\'\'"".',
type: 'switch',
},
{
name: 'autoCloseTags',
title: 'Auto-Close Tags',
description: 'Will auto-close XML tags when \'>\' or \'/\' is typed.',
type: 'switch',
},
{
name: 'showHint',
title: 'Show Hint',
description: '',
type: 'switch',
},
];
const themeCache = new Set<string>();
export const getCodemirrorEditor = (el: HTMLElement, options: EditorConfiguration): Editor => {
return CodeMirror(el, options);
};
export const initTheme = async (name?: string) => {
if (!name) name = 'darcula';
if (themeCache.has(name)) return;
await import(`codemirror/theme/${name}.css`);
themeCache.add(name);
};
export const getThemes = () => {
return themes;
};
export const getCodeMirrorTemplate = () => {
return template;
};
export const getOptionDefinition = (name: string): FileEditorOptionDefinition | undefined => {
return optionsDefinitions.find(d => d.name === name);
};

View File

@@ -0,0 +1,5 @@
import colors from '@/styles/color.scss';
export const getPredefinedColors = (): string[] => {
return Object.values(colors);
};

View File

@@ -0,0 +1,28 @@
import {plainClone} from '@/utils/object';
interface DebounceOptions {
delay?: number;
}
const defaultDebounceOptions: DebounceOptions = {
delay: 500,
};
const getDefaultDebounceOptions = (): DebounceOptions => {
return plainClone(defaultDebounceOptions);
};
const normalizeDebounceOptions = (options?: DebounceOptions): DebounceOptions => {
if (!options) options = getDefaultDebounceOptions();
if (!options.delay) options.delay = defaultDebounceOptions.delay;
return options;
};
export const debounce = <T = any>(fn: Function, options?: DebounceOptions): Function => {
let handle: number | null = null;
return () => {
if (handle) clearTimeout(handle);
const {delay} = normalizeDebounceOptions(options);
handle = setTimeout(fn, delay);
};
};

View File

@@ -1,28 +0,0 @@
export default {
docs: [
{
path: '/projects',
pattern: '^Project'
},
{
path: '/spiders',
pattern: '^Spider|^SDK|^Integration|^CI/Git'
},
{
path: '/tasks',
pattern: '^Task|^Architecture/Task'
},
{
path: '/schedules',
pattern: '^Schedule'
},
{
path: '/nodes',
pattern: '^Node|^Architecture/Node'
},
{
path: '/setting',
pattern: '^Notification'
}
]
}

View File

@@ -1,13 +0,0 @@
export default {
UUID: () => {
const s = []
const hexDigits = '0123456789abcdef'
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
}
s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010
s[8] = s[13] = s[18] = s[23] = '-'
return s.join('')
}
}

View File

@@ -0,0 +1,10 @@
import {ref} from 'vue';
export const getDefaultFormComponentData = <T>(defaultFn: DefaultFormFunc<T>) => {
return {
form: ref<T>(defaultFn()),
formRef: ref(),
formList: ref<T[]>([]),
formTableFieldRefsMap: ref<FormTableFieldRefsMap>(new Map()),
} as FormComponentData<T>;
};

View File

@@ -0,0 +1,11 @@
export const voidFunc = () => {
// do nothing
};
export const emptyObjectFunc = () => {
return {};
};
export const emptyArrayFunc = () => {
return [];
};

View File

@@ -0,0 +1,5 @@
import md5 from 'md5';
export const getMd5 = (text: string): string => {
return md5(text).toString();
};

View File

@@ -1,16 +0,0 @@
export default {
htmlEscape: text => {
return text.replace(/[<>"&]/g, function(match, pos, originalText) {
switch (match) {
case '<':
return '&lt;'
case '>':
return '&gt;'
case '&':
return '&amp;'
case '"':
return '&quot;'
}
})
}
}

View File

@@ -1,12 +0,0 @@
// translate router.meta.title, be used in breadcrumb sidebar tagsview
export function generateTitle(title) {
const hasKey = this.$te('route.' + title)
if (hasKey) {
// $t :this method from vue-i18n, inject in @/lang/index.js
const translatedTitle = this.$t('route.' + title)
return translatedTitle
}
return title
}

View File

@@ -1,15 +0,0 @@
import stats from './stats'
import encrypt from './encrypt'
import tour from './tour'
import log from './log'
import scrapy from './scrapy'
import doc from './doc'
export default {
stats,
encrypt,
tour,
log,
scrapy,
doc
}

View File

@@ -0,0 +1,28 @@
import {onBeforeMount, Ref} from 'vue';
import {Store} from 'vuex';
import {setupAutoUpdate} from '@/utils/auto';
export const getDefaultUseListOptions = <T = any>(navActions: Ref<ListActionGroup[]>, tableColumns: Ref<TableColumns<T>>): UseListOptions<T> => {
return {
navActions,
tableColumns,
};
};
export const setupGetAllList = (store: Store<RootStoreState>, allListNamespaces: ListStoreNamespace[]) => {
onBeforeMount(async () => {
await Promise.all(allListNamespaces?.map(ns => store.dispatch(`${ns}/getAllList`)) || []);
});
};
export const setupListComponent = (ns: ListStoreNamespace, store: Store<RootStoreState>, allListNamespaces?: ListStoreNamespace[]) => {
if (!allListNamespaces) allListNamespaces = [];
// get all list
setupGetAllList(store, allListNamespaces);
// auto update
setupAutoUpdate(async () => {
await store.dispatch(`${ns}/getList`);
});
};

View File

@@ -1,11 +0,0 @@
const regexToken = ' :,.'
export default {
// errorRegex: new RegExp(`(?:[${regexToken}]|^)((?:error|exception|traceback)s?)(?:[${regexToken}]|$)`, 'gi')
errorRegex: new RegExp(
`(?:[${regexToken}]|^)((?:error|exception|traceback)s?)(?:[${regexToken}]|$)`,
'gi'),
errorWhitelist: [
'log_count/ERROR'
]
}

View File

@@ -0,0 +1,5 @@
export const EMPTY_OBJECT_ID = '000000000000000000000000';
export const isZeroObjectId = (id: string): boolean => {
return !id || id === EMPTY_OBJECT_ID;
};

View File

@@ -0,0 +1,8 @@
export const plainClone = <T = any>(obj: T): T => {
if (obj === undefined || obj === null) return obj;
return JSON.parse(JSON.stringify(obj));
};
export const cloneArray = <T = any>(arr: T[]): T[] => {
return Array.from(arr);
};

View File

@@ -0,0 +1,6 @@
export const getDefaultPagination = (): TablePagination => {
return {
page: 1,
size: 10,
};
};

View File

@@ -0,0 +1,8 @@
export const getPrimaryPath = (path: string): string => {
const arr = path.split('/');
if (arr.length <= 1) {
return path;
} else {
return `/${arr[1]}`;
}
};

View File

@@ -1,123 +0,0 @@
import axios from 'axios'
import { MessageBox, Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
import i18n from '@/i18n'
import router from '@/router'
// const codeMessage = {
// 200: '服务器成功返回请求的数据。',
// 201: '新建或修改数据成功。',
// 202: '一个请求已经进入后台排队(异步任务)。',
// 204: '删除数据成功。',
// 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
// 401: '用户没有权限(令牌、用户名、密码错误)。',
// 403: '用户得到授权,但是访问是被禁止的。',
// 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
// 406: '请求的格式不可得。',
// 410: '请求的资源被永久删除,且不会再得到的。',
// 422: '当创建一个对象时,发生一个验证错误。',
// 500: '服务器发生错误,请检查服务器。',
// 502: '网关错误。',
// 503: '服务不可用,服务器暂时过载或维护。',
// 504: '网关超时。'
// }
/**
* 异常处理程序
*/
const errorHandler = (error) => {
const { response } = error
const routePath = router.currentRoute.path
if (response && response.status) {
const errorText = response.data.error
const { status } = response
Message({
message: i18n.t('Request Error') + ` ${status}: ${response.request.responseURL}. ${errorText}`,
type: 'error',
duration: 5 * 1000
})
switch (status) {
case 401:
if (routePath !== '/login' && routePath !== '/') {
MessageBox.confirm(
i18n.t('auth.login_expired_message'),
i18n.t('auth.login_expired_title'), {
confirmButtonText: i18n.t('auth.login_expired_confirm'),
cancelButtonText: i18n.t('auth.login_expired_cancel'),
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}
break
default:
}
} else if (!response) {
Message({
message: `您的网络发生异常,无法连接服务器`,
type: 'error',
duration: 5 * 1000
})
}
return response
}
// 根据 VUE_APP_BASE_URL 生成 baseUrl
let baseUrl = process.env.VUE_APP_BASE_URL
? process.env.VUE_APP_BASE_URL
: 'http://localhost:8000'
if (!baseUrl.match(/^https?/i)) {
baseUrl = `${window.location.protocol}//${window.location.host}${process.env.VUE_APP_BASE_URL}`
}
// 如果 Docker 中设置了 CRAWLAB_API_ADDRESS 这个环境变量,则会将 baseUrl 覆盖
const CRAWLAB_API_ADDRESS = '###CRAWLAB_API_ADDRESS###'
if (!CRAWLAB_API_ADDRESS.match('CRAWLAB_API_ADDRESS')) {
baseUrl = CRAWLAB_API_ADDRESS
}
// create an axios instance
const service = axios.create({
baseURL: baseUrl, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 15000 // request timeout
})
// request interceptor
service.interceptors.request.use(
config => {
// do something before request is sent
if (store.getters.token) {
// let each request carry token
// ['X-Token'] is a custom headers key
// please modify it according to the actual situation
config.headers['Authorization'] = getToken()
}
return config
},
error => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
// response interceptor
service.interceptors.response.use(
/**
* If you want to get http information such as headers or status
* Please return response => response
*/
/**
* Determine the request status by custom code
* Here is just an example
* You can also judge the status by HTTP Status Code
*/
response => {
return response
},
errorHandler
)
export default service

View File

@@ -0,0 +1,12 @@
export const getFieldsFromData = (data: TableData<Result>) => {
if (data.length === 0) {
return [];
}
const item = data[0];
if (typeof item !== 'object') return [];
return Object.keys(item).map(key => {
return {
key,
};
});
};

View File

@@ -0,0 +1,6 @@
export const getRoutePathByDepth = (path: string, depth?: number) => {
if (!depth) depth = 1;
const arr = path.split('/');
if (!arr[0]) depth += 1;
return arr.slice(0, depth).join('/');
};

View File

@@ -1,212 +0,0 @@
export default {
importantSettingParamNames: {
BOT_NAME: String,
SPIDER_MODULES: Array,
NEWSPIDER_MODULE: String,
USER_AGENT: String,
ROBOTSTXT_OBEY: Boolean,
CONCURRENT_REQUESTS: Number,
DOWNLOAD_DELAY: Number,
CONCURRENT_REQUESTS_PER_DOMAIN: Number,
CONCURRENT_REQUESTS_PER_IP: Number,
COOKIES_ENABLED: Boolean,
TELNETCONSOLE_ENABLED: Boolean,
DEFAULT_REQUEST_HEADERS: Object,
SPIDER_MIDDLEWARES: Object,
DOWNLOADER_MIDDLEWARES: Object,
EXTENSIONS: Object,
ITEM_PIPELINES: Object,
AUTOTHROTTLE_ENABLED: Boolean,
AUTOTHROTTLE_START_DELAY: Number,
AUTOTHROTTLE_MAX_DELAY: Number,
AUTOTHROTTLE_TARGET_CONCURRENCY: Number,
AUTOTHROTTLE_DEBUG: Boolean,
HTTPCACHE_ENABLED: Boolean,
HTTPCACHE_EXPIRATION_SECS: Number,
HTTPCACHE_DIR: String,
HTTPCACHE_IGNORE_HTTP_CODES: Array,
HTTPCACHE_STORAGE: String
},
settingParamNames: [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_ENDPOINT_URL',
'AWS_USE_SSL',
'AWS_VERIFY',
'AWS_REGION_NAME',
'BOT_NAME',
'CONCURRENT_ITEMS',
'CONCURRENT_REQUESTS',
'CONCURRENT_REQUESTS_PER_DOMAIN',
'CONCURRENT_REQUESTS_PER_IP',
'DEFAULT_ITEM_CLASS',
'DEFAULT_REQUEST_HEADERS',
'DEPTH_LIMIT',
'DEPTH_PRIORITY',
'DEPTH_STATS_VERBOSE',
'DNSCACHE_ENABLED',
'DNSCACHE_SIZE',
'DNS_TIMEOUT',
'DOWNLOADER',
'DOWNLOADER_HTTPCLIENTFACTORY',
'DOWNLOADER_CLIENTCONTEXTFACTORY',
'DOWNLOADER_CLIENT_TLS_CIPHERS',
'DOWNLOADER_CLIENT_TLS_METHOD',
'DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING',
'DOWNLOADER_MIDDLEWARES',
'DOWNLOADER_MIDDLEWARES_BASE',
'DOWNLOADER_STATS',
'DOWNLOAD_DELAY',
'DOWNLOAD_HANDLERS',
'DOWNLOAD_HANDLERS_BASE',
'DOWNLOAD_TIMEOUT',
'DOWNLOAD_MAXSIZE',
'DOWNLOAD_WARNSIZE',
'DOWNLOAD_FAIL_ON_DATALOSS',
'DUPEFILTER_CLASS',
'DUPEFILTER_DEBUG',
'EDITOR',
'EXTENSIONS',
'EXTENSIONS_BASE',
'FEED_TEMPDIR',
'FTP_PASSIVE_MODE',
'FTP_PASSWORD',
'FTP_USER',
'ITEM_PIPELINES',
'ITEM_PIPELINES_BASE',
'LOG_ENABLED',
'LOG_ENCODING',
'LOG_FILE',
'LOG_FORMAT',
'LOG_DATEFORMAT',
'LOG_FORMATTER',
'LOG_LEVEL',
'LOG_STDOUT',
'LOG_SHORT_NAMES',
'LOGSTATS_INTERVAL',
'MEMDEBUG_ENABLED',
'MEMDEBUG_NOTIFY',
'MEMUSAGE_ENABLED',
'MEMUSAGE_LIMIT_MB',
'MEMUSAGE_CHECK_INTERVAL_SECONDS',
'MEMUSAGE_NOTIFY_MAIL',
'MEMUSAGE_WARNING_MB',
'NEWSPIDER_MODULE',
'RANDOMIZE_DOWNLOAD_DELAY',
'REACTOR_THREADPOOL_MAXSIZE',
'REDIRECT_MAX_TIMES',
'REDIRECT_PRIORITY_ADJUST',
'RETRY_PRIORITY_ADJUST',
'ROBOTSTXT_OBEY',
'ROBOTSTXT_PARSER',
'SCHEDULER',
'SCHEDULER_DEBUG',
'SCHEDULER_DISK_QUEUE',
'SCHEDULER_MEMORY_QUEUE',
'SCHEDULER_PRIORITY_QUEUE',
'SPIDER_CONTRACTS',
'SPIDER_CONTRACTS_BASE',
'SPIDER_LOADER_CLASS',
'SPIDER_LOADER_WARN_ONLY',
'SPIDER_MIDDLEWARES',
'SPIDER_MIDDLEWARES_BASE',
'SPIDER_MODULES',
'STATS_CLASS',
'STATS_DUMP',
'STATSMAILER_RCPTS',
'TELNETCONSOLE_ENABLED',
'TELNETCONSOLE_PORT',
'TEMPLATES_DIR',
'URLLENGTH_LIMIT',
'USER_AGENT',
'AJAXCRAWL_ENABLED',
'AUTOTHROTTLE_DEBUG',
'AUTOTHROTTLE_ENABLED',
'AUTOTHROTTLE_MAX_DELAY',
'AUTOTHROTTLE_START_DELAY',
'AUTOTHROTTLE_TARGET_CONCURRENCY',
'CLOSESPIDER_ERRORCOUNT',
'CLOSESPIDER_ITEMCOUNT',
'CLOSESPIDER_PAGECOUNT',
'CLOSESPIDER_TIMEOUT',
'COMMANDS_MODULE',
'COMPRESSION_ENABLED',
'COOKIES_DEBUG',
'COOKIES_ENABLED',
'FEED_EXPORTERS',
'FEED_EXPORTERS_BASE',
'FEED_EXPORT_ENCODING',
'FEED_EXPORT_FIELDS',
'FEED_EXPORT_INDENT',
'FEED_FORMAT',
'FEED_STORAGES',
'FEED_STORAGES_BASE',
'FEED_STORAGE_FTP_ACTIVE',
'FEED_STORAGE_S3_ACL',
'FEED_STORE_EMPTY',
'FEED_URI',
'FILES_EXPIRES',
'FILES_RESULT_FIELD',
'FILES_STORE',
'FILES_STORE_GCS_ACL',
'FILES_STORE_S3_ACL',
'FILES_URLS_FIELD',
'GCS_PROJECT_ID',
'HTTPCACHE_ALWAYS_STORE',
'HTTPCACHE_DBM_MODULE',
'HTTPCACHE_DIR',
'HTTPCACHE_ENABLED',
'HTTPCACHE_EXPIRATION_SECS',
'HTTPCACHE_GZIP',
'HTTPCACHE_IGNORE_HTTP_CODES',
'HTTPCACHE_IGNORE_MISSING',
'HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS',
'HTTPCACHE_IGNORE_SCHEMES',
'HTTPCACHE_POLICY',
'HTTPCACHE_STORAGE',
'HTTPERROR_ALLOWED_CODES',
'HTTPERROR_ALLOW_ALL',
'HTTPPROXY_AUTH_ENCODING',
'HTTPPROXY_ENABLED',
'IMAGES_EXPIRES',
'IMAGES_MIN_HEIGHT',
'IMAGES_MIN_WIDTH',
'IMAGES_RESULT_FIELD',
'IMAGES_STORE',
'IMAGES_STORE_GCS_ACL',
'IMAGES_STORE_S3_ACL',
'IMAGES_THUMBS',
'IMAGES_URLS_FIELD',
'MAIL_FROM',
'MAIL_HOST',
'MAIL_PASS',
'MAIL_PORT',
'MAIL_SSL',
'MAIL_TLS',
'MAIL_USER',
'MEDIA_ALLOW_REDIRECTS',
'METAREFRESH_ENABLED',
'METAREFRESH_IGNORE_TAGS',
'METAREFRESH_MAXDELAY',
'REDIRECT_ENABLED',
'REDIRECT_MAX_TIMES',
'REFERER_ENABLED',
'REFERRER_POLICY',
'RETRY_ENABLED',
'RETRY_HTTP_CODES',
'RETRY_TIMES',
'TELNETCONSOLE_HOST',
'TELNETCONSOLE_PASSWORD',
'TELNETCONSOLE_PORT',
'TELNETCONSOLE_USERNAME',
'REDIS_ITEMS_KEY',
'REDIS_ITEMS_SERIALIZER',
'REDIS_HOST',
'REDIS_PORT',
'REDIS_URL',
'REDIS_PARAMS',
'REDIS_START_URLS_AS_SET',
'REDIS_START_URLS_KEY',
'REDIS_ENCODING'
]
}

View File

@@ -0,0 +1,23 @@
import {Store} from 'vuex';
export const getDefaultService = <T>(ns: string, store: Store<RootStoreState>): Services<T> => {
const {dispatch} = store;
return {
getById: (id: string) => dispatch(`${ns}/getById`, id),
create: (form: T) => dispatch(`${ns}/create`, form),
updateById: (id: string, form: T) => dispatch(`${ns}/updateById`, {id, form}),
deleteById: (id: string) => dispatch(`${ns}/deleteById`, id),
getList: (params?: ListRequestParams) => {
if (params) {
return dispatch(`${ns}/getListWithParams`, params);
} else {
return dispatch(`${ns}/getList`);
}
},
getAll: () => dispatch(`${ns}/getAllList`),
createList: (data: T[]) => dispatch(`${ns}/createList`, data),
updateList: (ids: string[], data: T, fields: string[]) => dispatch(`${ns}/updateList`, {ids, data, fields}),
deleteList: (ids: string[]) => dispatch(`${ns}/deleteList`, ids),
};
};

View File

@@ -0,0 +1,3 @@
export const sleep = (duration: number) => {
return new Promise(resolve => setTimeout(resolve, duration));
};

View File

@@ -1,29 +0,0 @@
import axios from 'axios'
const sendEvCrawlab = async(eventCategory, eventAction, eventLabel) => {
await axios.get(process.env.VUE_APP_CRAWLAB_BASE_URL + '/track', {
params: {
uid: localStorage.getItem('uid'),
sid: sessionStorage.getItem('sid'),
ec: eventCategory,
ea: eventAction,
el: eventLabel,
v: sessionStorage.getItem('v')
}
})
}
export default {
sendPv(page) {
if (localStorage.getItem('useStats') !== '0') {
window._hmt.push(['_trackPageview', page])
sendEvCrawlab('访问页面', page, '')
}
},
sendEv(category, eventName, optLabel, optValue) {
if (localStorage.getItem('useStats') !== '0') {
window._hmt.push(['_trackEvent', category, eventName, optLabel, optValue])
sendEvCrawlab(category, eventName, optLabel)
}
}
}

View File

@@ -0,0 +1,28 @@
import dayjs, {Dayjs} from 'dayjs';
export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
export const spanDateRange = (start: Dayjs | string, end: Dayjs | string, data: StatsResult[], dateKey?: string): StatsResult[] => {
// date key
const key = dateKey || 'date';
// format
const format = DEFAULT_DATE_FORMAT;
// cache data
const cache = new Map<string, StatsResult>();
data.forEach(d => cache.set(d[key], d));
// results
const results = [] as StatsResult[];
// iterate
for (let date = dayjs(start, format); date.format(format) <= dayjs(end, format).format(format); date = date.add(1, 'day')) {
let item = cache.get(date.format(format));
if (!item) item = {};
item[key] = date.format(format);
results.push(item);
}
return results;
};

263
frontend/src/utils/store.ts Normal file
View File

@@ -0,0 +1,263 @@
import {getDefaultPagination} from '@/utils/pagination';
import {useService} from '@/services';
import router from '@/router';
import {plainClone} from '@/utils/object';
export const getDefaultStoreState = <T = any>(ns: StoreNamespace): BaseStoreState<T> => {
return {
ns,
dialogVisible: {
createEdit: true,
},
activeDialogKey: undefined,
createEditDialogTabName: 'single',
form: {} as T,
isSelectiveForm: false,
selectedFormFields: [],
readonlyFormFields: [],
formList: [],
confirmLoading: false,
tableData: [],
tableTotal: 0,
tablePagination: getDefaultPagination(),
tableListFilter: [],
tableListSort: [],
allList: [],
sidebarCollapsed: false,
actionsCollapsed: false,
tabs: [{id: 'overview', title: 'Overview'}],
afterSave: [],
};
};
export const getDefaultStoreGetters = <T = any>(opts?: GetDefaultStoreGettersOptions): BaseStoreGetters<BaseStoreState<T>> => {
if (!opts) opts = {};
if (!opts.selectOptionValueKey) opts.selectOptionValueKey = '_id';
if (!opts.selectOptionLabelKey) opts.selectOptionLabelKey = 'name';
return {
dialogVisible: (state: BaseStoreState<T>) => state.activeDialogKey !== undefined,
// isBatchForm: (state: BaseStoreState<T>) => state.isSelectiveForm || state.createEditDialogTabName === 'batch',
isBatchForm: (state: BaseStoreState<T>) => {
return state.isSelectiveForm || state.createEditDialogTabName === 'batch';
},
formListIds: (state: BaseStoreState<BaseModel>) => state.formList.map(d => d._id as string),
allListSelectOptions: (state: BaseStoreState<BaseModel>) => state.allList.map(d => {
return {
value: d[opts?.selectOptionValueKey as string],
label: d[opts?.selectOptionLabelKey as string],
};
}),
allDict: (state: BaseStoreState<BaseModel>) => {
const dict = new Map<string, T>();
state.allList.forEach(d => dict.set(d._id as string, d as any));
return dict;
},
tabName: () => {
const arr = router.currentRoute.value.path.split('/');
if (arr.length < 3) return '';
return arr[3];
},
allTags: (state: BaseStoreState<T>, getters, rootState) => {
return rootState.tag.allList.filter(d => d.col === `${state.ns}s`);
},
};
};
export const getDefaultStoreMutations = <T = any>(): BaseStoreMutations<T> => {
return {
showDialog: (state: BaseStoreState<T>, key: DialogKey) => {
state.activeDialogKey = key;
},
hideDialog: (state: BaseStoreState<T>) => {
// reset all other state variables
state.createEditDialogTabName = 'single';
state.isSelectiveForm = false;
state.selectedFormFields = [];
state.formList = [];
state.confirmLoading = false;
// set active dialog key as undefined
state.activeDialogKey = undefined;
},
setCreateEditDialogTabName: (state: BaseStoreState<T>, tabName: CreateEditTabName) => {
state.createEditDialogTabName = tabName;
},
resetCreateEditDialogTabName: (state: BaseStoreState<T>) => {
state.createEditDialogTabName = 'single';
},
setForm: (state: BaseStoreState<T>, value: T) => {
state.form = value;
},
resetForm: (state: BaseStoreState<T>) => {
state.form = {} as T;
},
setIsSelectiveForm: (state: BaseStoreState<T>, value: boolean) => {
state.isSelectiveForm = value;
},
setSelectedFormFields: (state: BaseStoreState<T>, value: string[]) => {
state.selectedFormFields = value;
},
resetSelectedFormFields: (state: BaseStoreState<T>) => {
state.selectedFormFields = [];
},
setReadonlyFormFields: (state: BaseStoreState<T>, value: string[]) => {
state.readonlyFormFields = value;
},
resetReadonlyFormFields: (state: BaseStoreState<T>) => {
state.readonlyFormFields = [];
},
setFormList: (state: BaseStoreState<T>, value: T[]) => {
state.formList = value;
},
resetFormList: (state: BaseStoreState<T>) => {
state.formList = [];
},
setConfirmLoading: (state: BaseStoreState<T>, value: boolean) => {
state.confirmLoading = value;
},
resetTableData: (state: BaseStoreState<T>) => {
state.tableData = [];
},
setTableData: (state: BaseStoreState<T>, payload: TableDataWithTotal<T>) => {
const {data, total} = payload;
state.tableData = data;
state.tableTotal = total;
},
setTablePagination: (state: BaseStoreState<T>, pagination: TablePagination) => {
state.tablePagination = pagination;
},
resetTablePagination: (state: BaseStoreState<T>) => {
state.tablePagination = getDefaultPagination();
},
setTableListFilter: (state: BaseStoreState<T>, filter: FilterConditionData[]) => {
state.tableListFilter = filter;
},
resetTableListFilter: (state: BaseStoreState<T>) => {
state.tableListFilter = [];
},
setTableListFilterByKey: (state: BaseStoreState<T>, {key, conditions}) => {
const filter = state.tableListFilter.filter(d => d.key !== key);
conditions.forEach(d => {
d.key = key;
filter.push(d);
});
state.tableListFilter = filter;
},
resetTableListFilterByKey: (state: BaseStoreState<T>, key) => {
state.tableListFilter = state.tableListFilter.filter(d => d.key !== key);
},
setTableListSort: (state: BaseStoreState<T>, sort: SortData[]) => {
state.tableListSort = sort;
},
resetTableListSort: (state: BaseStoreState<T>) => {
state.tableListSort = [];
},
setTableListSortByKey: (state: BaseStoreState<T>, {key, sort}) => {
const idx = state.tableListSort.findIndex(d => d.key === key);
if (idx === -1) {
if (sort) {
state.tableListSort.push(sort);
}
} else {
if (sort) {
state.tableListSort[idx] = plainClone(sort);
} else {
state.tableListSort.splice(idx, 1);
}
}
},
resetTableListSortByKey: (state: BaseStoreState<T>, key) => {
state.tableListSort = state.tableListSort.filter(d => d.key !== key);
},
setAllList: (state: BaseStoreState<T>, value: T[]) => {
state.allList = value;
},
resetAllList: (state: BaseStoreState<T>) => {
state.allList = [];
},
expandSidebar: (state: BaseStoreState<T>) => {
state.sidebarCollapsed = false;
},
collapseSidebar: (state: BaseStoreState<T>) => {
state.sidebarCollapsed = true;
},
expandActions: (state: BaseStoreState<T>) => {
state.actionsCollapsed = false;
},
collapseActions: (state: BaseStoreState<T>) => {
state.actionsCollapsed = true;
},
setAfterSave: (state: BaseStoreState<T>, fnList) => {
state.afterSave = fnList;
},
};
};
export const getDefaultStoreActions = <T = any>(endpoint: string): BaseStoreActions<T> => {
const {
getById,
create,
updateById,
deleteById,
getList,
getAll,
createList,
updateList,
deleteList,
} = useService<T>(endpoint);
return {
getById: async ({commit}: StoreActionContext<BaseStoreState<T>>, id: string) => {
const res = await getById(id);
commit('setForm', res.data);
return res;
},
create: async ({commit}: StoreActionContext<BaseStoreState<T>>, form: T) => {
const res = await create(form);
return res;
},
updateById: async ({commit}: StoreActionContext<BaseStoreState<T>>, {id, form}: { id: string; form: T }) => {
const res = await updateById(id, form);
return res;
},
deleteById: async ({commit}: StoreActionContext<BaseStoreState<T>>, id: string) => {
const res = await deleteById(id);
return res;
},
getList: async ({state, commit}: StoreActionContext<BaseStoreState<T>>) => {
const {page, size} = state.tablePagination;
const res = await getList({
page,
size,
conditions: JSON.stringify(state.tableListFilter),
sort: JSON.stringify(state.tableListSort),
} as ListRequestParams);
commit('setTableData', {data: res.data || [], total: res.total});
return res;
},
getListWithParams: async (_: StoreActionContext<BaseStoreState<T>>, params?: ListRequestParams) => {
return await getList(params);
},
getAllList: async ({commit}: StoreActionContext<BaseStoreState<T>>) => {
const res = await getAll();
commit('setAllList', res.data || []);
return res;
},
createList: async ({state, commit}: StoreActionContext<BaseStoreState<T>>, data: T[]) => {
const res = await createList(data);
return res;
},
updateList: async ({state, commit}: StoreActionContext<BaseStoreState<T>>, {
ids,
data,
fields,
}: BatchRequestPayloadWithData) => {
const res = await updateList(ids, data, fields);
return res;
},
deleteList: async ({commit}: StoreActionContext<BaseStoreState<T>>, ids: string[]) => {
return await deleteList(ids);
},
};
};

View File

@@ -0,0 +1,6 @@
export const capitalize = (str: string): string => {
if (!str) return '';
const arr = str.split('');
arr[0] = arr[0].toUpperCase();
return arr.join('');
};

129
frontend/src/utils/table.ts Normal file
View File

@@ -0,0 +1,129 @@
import {ElMessageBox} from 'element-plus';
import {useStore} from 'vuex';
import {useRouter} from 'vue-router';
import {ACTION_CANCEL, ACTION_CLONE, ACTION_DELETE, ACTION_EDIT, ACTION_RUN, ACTION_VIEW,} from '@/constants/action';
import {TABLE_COLUMN_NAME_ACTIONS} from '@/constants/table';
export const getDefaultTableDataWithTotal = (): TableDataWithTotal => {
return {
data: [],
total: 0,
};
};
export const getTableWidth = (): number | undefined => {
const elTable = document.querySelector('.list-layout .table');
if (!elTable) return;
const style = getComputedStyle(elTable);
const widthStr = style.width.replace('px', '');
const width = Number(widthStr);
if (isNaN(width)) return;
return width;
};
export const getColumnWidth = (column: TableColumn): number | undefined => {
let width: number;
if (typeof column.width === 'string') {
width = Number(column.width.replace('px', ''));
if (isNaN(width)) return;
return width;
}
{
return column.width;
}
};
export const getActionColumn = (endpoint: string, ns: ListStoreNamespace, actionNames: TableActionName[]): TableColumn => {
const store = useStore();
const router = useRouter();
const column = {
key: TABLE_COLUMN_NAME_ACTIONS,
label: 'Actions',
fixed: 'right',
width: '200',
buttons: [],
} as TableColumn;
const buttons = typeof column.buttons === 'function' ? column.buttons() : column.buttons as TableColumnButton[];
actionNames.forEach(name => {
if (!buttons) return;
switch (name) {
case ACTION_VIEW:
buttons.push({
type: 'primary',
icon: ['fa', 'search'],
tooltip: 'View',
onClick: (row: BaseModel) => {
router.push(`${endpoint}/${row._id}`);
},
});
break;
case ACTION_EDIT:
buttons.push({
type: 'warning',
icon: ['fa', 'edit'],
tooltip: 'Edit',
onClick: (row: BaseModel) => {
store.commit(`${ns}/setForm`, row);
store.commit(`${ns}/showDialog`, 'edit');
},
},);
break;
case ACTION_CLONE:
buttons.push({
type: 'info',
size: 'mini',
icon: ['fa', 'clone'],
tooltip: 'Clone',
onClick: (row: BaseModel) => {
// TODO: implement
console.log('clone', row);
}
});
break;
case ACTION_DELETE:
buttons.push({
type: 'danger',
size: 'mini',
icon: ['fa', 'trash-alt'],
tooltip: 'Delete',
onClick: async (row: BaseModel) => {
const res = await ElMessageBox.confirm('Are you sure to delete?', 'Delete');
if (res) {
await store.dispatch(`${ns}/deleteById`, row._id as string);
}
await store.dispatch(`${ns}/getList`);
},
});
break;
case ACTION_RUN:
buttons.push({
type: 'success',
size: 'mini',
icon: ['fa', 'play'],
tooltip: 'Run',
onClick: async (row: BaseModel) => {
store.commit(`${ns}/setForm`, row);
store.commit(`${ns}/showDialog`, 'run');
},
});
break;
case ACTION_CANCEL:
buttons.push({
type: 'info',
size: 'mini',
icon: ['fa', 'pause'],
tooltip: 'Cancel',
onClick: async (row: BaseModel) => {
// TODO: implement
console.log('cancel', row);
},
});
break;
}
});
return column;
};

View File

@@ -0,0 +1,49 @@
import {
TASK_MODE_ALL,
TASK_MODE_RANDOM,
TASK_MODE_SELECTED_NODE_TAGS,
TASK_MODE_SELECTED_NODES,
TASK_STATUS_PENDING,
TASK_STATUS_RUNNING
} from '@/constants/task';
export const getPriorityLabel = (priority: number): string => {
if (priority <= 2) {
return `High - ${priority}`;
} else if (priority <= 4) {
return `Higher - ${priority}`;
} else if (priority <= 6) {
return `Medium - ${priority}`;
} else if (priority <= 8) {
return `Lower - ${priority}`;
} else {
return `Low - ${priority}`;
}
};
export const isCancellable = (status: TaskStatus): boolean => {
switch (status) {
case TASK_STATUS_PENDING:
case TASK_STATUS_RUNNING:
return true;
default:
return false;
}
};
export const getModeOptions = (): SelectOption[] => {
return [
{value: TASK_MODE_RANDOM, label: 'Random Node'},
{value: TASK_MODE_ALL, label: 'All Nodes'},
{value: TASK_MODE_SELECTED_NODES, label: 'Selected Nodes'},
{value: TASK_MODE_SELECTED_NODE_TAGS, label: 'Selected Tags'},
];
};
export const getModeOptionsDict = (): Map<string, SelectOption> => {
const modeOptions = getModeOptions();
const dict = new Map<string, SelectOption>();
modeOptions.forEach(op => dict.set(op.value, op));
return dict;
};

View File

@@ -1,71 +0,0 @@
import i18n from '../i18n'
import store from '../store'
import stats from './stats'
export default {
isFinishedTour: (tourName) => {
if (!localStorage.getItem('tour')) {
localStorage.setItem('tour', JSON.stringify({}))
return false
}
let data
try {
data = JSON.parse(localStorage.getItem('tour'))
} catch (e) {
localStorage.setItem('tour', JSON.stringify({}))
return false
}
return !!data[tourName]
},
startTour: (vm, tourName) => {
if (localStorage.getItem('enableTutorial') === '0') return
vm.$tours[tourName].start()
vm.$st.sendEv('教程', '开始', tourName)
},
finishTour: (tourName) => {
let data
try {
data = JSON.parse(localStorage.getItem('tour'))
} catch (e) {
localStorage.setItem('tour', JSON.stringify({}))
data = {}
}
data[tourName] = 1
localStorage.setItem('tour', JSON.stringify(data))
// 发送统计数据
const finalStep = store.state.tour.tourFinishSteps[tourName]
const currentStep = store.state.tour.tourSteps[tourName]
if (currentStep === finalStep) {
stats.sendEv('教程', '完成', tourName)
} else {
stats.sendEv('教程', '跳过', tourName)
}
},
nextStep: (tourName, currentStep) => {
store.commit('tour/SET_TOUR_STEP', {
tourName,
step: currentStep + 1
})
stats.sendEv('教程', '下一步', tourName)
},
prevStep: (tourName, currentStep) => {
store.commit('tour/SET_TOUR_STEP', {
tourName,
step: currentStep - 1
})
stats.sendEv('教程', '上一步', tourName)
},
getOptions: (isShowHighlight) => {
return {
labels: {
buttonSkip: i18n.t('Skip'),
buttonPrevious: i18n.t('Previous'),
buttonNext: i18n.t('Next'),
buttonStop: i18n.t('Finish')
},
highlight: isShowHighlight
}
}
}

View File

@@ -1,15 +0,0 @@
/**
* Created by jiachenpan on 16/11/18.
*/
export function isValidUsername(str) {
if (!str) return false
if (str.length > 100) return false
return true
// const validMap = ['admin', 'editor']
// return validMap.indexOf(str.trim()) >= 0
}
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}

View File

@@ -0,0 +1,11 @@
export const isValidUsername = (str: string): boolean => {
if (!str) return false;
if (str.length > 100) return false;
return true;
// const validMap = ['admin', 'editor']
// return validMap.indexOf(str.trim()) >= 0
};
export const isExternal = (path: string) => {
return /^(https?:|mailto:|tel:)/.test(path);
};