Commit 0b1c698e by 周田

init

parents
VITE_APP_BASE_API = '/api'
# 开发环境配置
VITE_APP_BASE_API = '/api'
VITE_APP_TITLE = 'Spider Admin Pro'
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
{
"recommendations": ["Vue.volar"]
}
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "fk-spider-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.17",
"@types/nprogress": "^0.2.3",
"@types/uuid": "^10.0.0",
"axios": "^1.9.0",
"echarts": "^5.6.0",
"element-plus": "^2.9.10",
"js-cookie": "^3.0.5",
"normalize.css": "^8.0.1",
"nprogress": "~0.2.0",
"path-to-regexp": "^3.3.0",
"pinia": "^3.0.2",
"qs": "~6.11.2",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"sass-embedded": "^1.88.0",
"typescript": "~5.8.3",
"unocss": "^66.1.2",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.1
version: 2.3.1(vue@3.5.13(typescript@5.8.3))
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
'@types/node':
specifier: ^22.15.17
version: 22.15.17
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
axios:
specifier: ^1.9.0
version: 1.9.0
echarts:
specifier: ^5.6.0
version: 5.6.0
element-plus:
specifier: ^2.9.10
version: 2.9.10(vue@3.5.13(typescript@5.8.3))
js-cookie:
specifier: ^3.0.5
version: 3.0.5
normalize.css:
specifier: ^8.0.1
version: 8.0.1
nprogress:
specifier: ~0.2.0
version: 0.2.0
path-to-regexp:
specifier: ^3.3.0
version: 3.3.0
pinia:
specifier: ^3.0.2
version: 3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
qs:
specifier: ~6.11.2
version: 6.11.2
uuid:
specifier: ^11.1.0
version: 11.1.0
vue:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
vue-router:
specifier: ^4.5.1
version: 4.5.1(vue@3.5.13(typescript@5.8.3))
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.3
version: 5.2.4(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))
'@vue/tsconfig':
specifier: ^0.7.0
version: 0.7.0(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
sass-embedded:
specifier: ^1.88.0
version: 1.88.0
typescript:
specifier: ~5.8.3
version: 5.8.3
unocss:
specifier: ^66.1.2
version: 66.1.2(postcss@8.5.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))
vite:
specifier: ^6.3.5
version: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0)
vue-tsc:
specifier: ^2.2.8
version: 2.2.10(typescript@5.8.3)
packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.27.2':
resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.27.1':
resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==}
engines: {node: '>=6.9.0'}
'@bufbuild/protobuf@2.4.0':
resolution: {integrity: sha512-RN9M76x7N11QRihKovEglEjjVCQEA9PRBVnDgk9xw8JHLrcUrp4FpAVSPSH91cNbcTft3u2vpLN4GMbiKY9PJw==}
'@ctrl/tinycolor@3.6.1':
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'}
'@element-plus/icons-vue@2.3.1':
resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==}
peerDependencies:
vue: ^3.2.0
'@esbuild/aix-ppc64@0.25.4':
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.4':
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.4':
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.4':
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.4':
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.4':
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.4':
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.4':
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.4':
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.4':
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.4':
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.4':
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.4':
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.4':
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.4':
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.4':
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.4':
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.4':
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.4':
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.4':
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.4':
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.25.4':
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.4':
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.4':
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.4':
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@floating-ui/core@1.7.0':
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
'@floating-ui/dom@1.7.0':
resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@jridgewell/gen-mapping@0.3.8':
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
engines: {node: '>=6.0.0'}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@quansync/fs@0.1.3':
resolution: {integrity: sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg==}
engines: {node: '>=20.0.0'}
'@rollup/rollup-android-arm-eabi@4.40.2':
resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.40.2':
resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.40.2':
resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.40.2':
resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.40.2':
resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.40.2':
resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.40.2':
resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.2':
resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.2':
resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.2':
resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.2':
resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.2':
resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.2':
resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.2':
resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.40.2':
resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.40.2':
resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==}
cpu: [x64]
os: [win32]
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.16':
resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==}
'@types/node@22.15.17':
resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==}
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@unocss/astro@66.1.2':
resolution: {integrity: sha512-QBcvrPp0F2jqe2Y/S/FQDmEmNlAhGjeWN5fkUGj02N7mXRrg0/VJxSpOJH6XHRWkMoFPoNNyEjHk563ODbjtHw==}
peerDependencies:
vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0
peerDependenciesMeta:
vite:
optional: true
'@unocss/cli@66.1.2':
resolution: {integrity: sha512-bYCRpkGMu0QwC6Ktq3S/HwtcIW8Famy0dXOu1RIAM1IT60lq+4S5UTEBPdwryoFgDBoVMB7KLUhPYiGQ3pmSTA==}
engines: {node: '>=14'}
hasBin: true
'@unocss/config@66.1.2':
resolution: {integrity: sha512-2sQXj+Qaq4RVDELVTPoXMggZ30g1WKHeCuur396I12Ab0HgAR6bTc/DIrNtqKVHFI3mmlvP1oM1ynhKWSKPsTg==}
engines: {node: '>=14'}
'@unocss/core@66.1.2':
resolution: {integrity: sha512-mN9h1hHEuhDcdbI4z74o7UnxlBZYVsJpYcdC1YLWBKROcLYTkuyZ7hgBzpo1FBNox2Bt3JnrSinVDmc44Bxjow==}
'@unocss/extractor-arbitrary-variants@66.1.2':
resolution: {integrity: sha512-F570wH9VYeFTb4r8qgcbN5QpEVIAvFC1zOnrAPUr6B6kbU2YChMXxHP7PHK0AzLHnEr458Pwpzl6hmP6bzxZ8g==}
'@unocss/inspector@66.1.2':
resolution: {integrity: sha512-ftdZzFP5DAKDzgBI078xDDZbNNVq1RV/yhpNkviBvWCUsgRWc6o3G8swqJPIvFaphmUms0RIYH9shmXilVXFtA==}
'@unocss/postcss@66.1.2':
resolution: {integrity: sha512-RCA3or1qBdRVduNW73xdeiFDCEb8cvcGKsHSN66rL66RrlzNnunE4NE55vbI+yoArTRZ7RdUnxq1KuXKjrJbYw==}
engines: {node: '>=14'}
peerDependencies:
postcss: ^8.4.21
'@unocss/preset-attributify@66.1.2':
resolution: {integrity: sha512-i7+LRtpxbtSzS+gHdc+aW99mGLYeR8hUnEWqFNnr+MiiyzbD8yFimye/u8TySSBLzPKGbLCb4YWVV684BuZgxA==}
'@unocss/preset-icons@66.1.2':
resolution: {integrity: sha512-14390jFBJ2anuKvjX9TeRCm7adNjR/mey0bh0+S/k/5W3VugIY2y0E+OH3m+sx5d/5ZUYbYkUGsmtuKbVNwwxQ==}
'@unocss/preset-mini@66.1.2':
resolution: {integrity: sha512-oiDe+VhwZ8B5Z0UGfggtOwgpRZMLtH1RTDFvmJmJEXYYX5BPWknS6wYcQzxy0i/y9ym0xp2QnEaTpGmR7LKdkg==}
'@unocss/preset-tagify@66.1.2':
resolution: {integrity: sha512-Xw5sFJGuzmGnfAXMI0kAiWDBh4DT3cOyphcyY9grBxbmxgqQDxRFHOV3Eg85lWK6X5cScOv3DhO0ndGv5ND8YA==}
'@unocss/preset-typography@66.1.2':
resolution: {integrity: sha512-+k9zp27Ak8rB6LPFDwq9fcwd3+ivFeSvXFQ2d4fBCwGGOAKHIA7qHLg3etxRaMhGd3YUPv/6d7FWpBbQgUVYZw==}
'@unocss/preset-uno@66.1.2':
resolution: {integrity: sha512-JL9YkDwluu1YGhzBaxO60XkKtZBagL13z3K6dsjsghbs+dKVlh35rhlIm5TZ+NdLAzcLM8PHhXm2ausjSd54Bg==}
'@unocss/preset-web-fonts@66.1.2':
resolution: {integrity: sha512-2ru+6jaac72oUx0kOBgNzbbkVe6oWKjqGmx24uK94fAcrP9eQyd+r7xiFpqXegrQ8+kONI66+HxAClvF2JHqdw==}
'@unocss/preset-wind3@66.1.2':
resolution: {integrity: sha512-S09imGOngAAOXCBCHb3JAtxD1/L7nDWrgEeX6NT0ElDp3X1T6XxUXYJlpjCfcqV/klMoXyYouKvp0YuG9QSgVg==}
'@unocss/preset-wind4@66.1.2':
resolution: {integrity: sha512-03p4rpBAWzz58BzAiKsUuG+6YO7IG6mJMGQAtPzuhd+nVBJLIRa3eBIVXOPmAVz1rNx5XPRTAr6PMC7ycdMFRA==}
'@unocss/preset-wind@66.1.2':
resolution: {integrity: sha512-O3nIfbTbX/YRMFj7jNb7nHBDV47G79qOmyid4WPFZrPV3BbFAo94d/54kSoDVuc8jAt06YYQH9XC4ZeD59Sr3Q==}
'@unocss/reset@66.1.2':
resolution: {integrity: sha512-njNy/QCpuPKBFeEvhYGwwCe3t8R8JTxONsyUB9NsFOamkF13DSlEB4Yy/QLQfIinbbmx0F/wiej/JGOJk1ecDg==}
'@unocss/rule-utils@66.1.2':
resolution: {integrity: sha512-nn0ehvDh7yyWq2mcBDLVpmMAivjRATUroZ8ETinyN1rmfsGesm71R0d1gV3K+Z6YC7a3+dMLc+/qzI7VK3AG/Q==}
engines: {node: '>=14'}
'@unocss/transformer-attributify-jsx@66.1.2':
resolution: {integrity: sha512-PNwxpsQlBlTAyw1apIMyioeAKrLAf7axLDjZ4BW20WH7ql0GUwvMhuO/qzsWDpYWdtSlFnnAdWI2aCxyvhzdCA==}
'@unocss/transformer-compile-class@66.1.2':
resolution: {integrity: sha512-viJetYFncLf9llxYQ7DKf5PuSJw08B7qhp0IXv/7ZG7agU09J1mlussC6ff+00iRoMxvG+5uXiYlTzL2vfikwA==}
'@unocss/transformer-directives@66.1.2':
resolution: {integrity: sha512-A41/cPMB+BUEgnhz5kFiTYgSuCAziJy6hSlLYBDcrFbARUsvmhZFou0P2fRr3wDOFxD3BuApHjsefybKTh1UeA==}
'@unocss/transformer-variant-group@66.1.2':
resolution: {integrity: sha512-RfqJmeic4kAwS5OhSk/D00hqla+xXIw8AJH93jYqHfyDhJR5vddEAJi5RBMOL7y6vDQqRlUCEDQvfp3zSmi6iw==}
'@unocss/vite@66.1.2':
resolution: {integrity: sha512-ZJHN8+HKSrclVjT/+S7Vh2t59DK8J44d5nLZPG1Goua7uNK8yYJeOLK2sCGX7aackRer1ZynmglFFzxNFVt+IA==}
peerDependencies:
vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@volar/language-core@2.4.13':
resolution: {integrity: sha512-MnQJ7eKchJx5Oz+YdbqyFUk8BN6jasdJv31n/7r6/WwlOOv7qzvot6B66887l2ST3bUW4Mewml54euzpJWA6bg==}
'@volar/source-map@2.4.13':
resolution: {integrity: sha512-l/EBcc2FkvHgz2ZxV+OZK3kMSroMr7nN3sZLF2/f6kWW66q8+tEL4giiYyFjt0BcubqJhBt6soYIrAPhg/Yr+Q==}
'@volar/typescript@2.4.13':
resolution: {integrity: sha512-Ukz4xv84swJPupZeoFsQoeJEOm7U9pqsEnaGGgt5ni3SCTa22m8oJP5Nng3Wed7Uw5RBELdLxxORX8YhJPyOgQ==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
'@vue/compiler-dom@3.5.13':
resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==}
'@vue/compiler-sfc@3.5.13':
resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==}
'@vue/compiler-ssr@3.5.13':
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.6':
resolution: {integrity: sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==}
'@vue/devtools-kit@7.7.6':
resolution: {integrity: sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==}
'@vue/devtools-shared@7.7.6':
resolution: {integrity: sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==}
'@vue/language-core@2.2.10':
resolution: {integrity: sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
'@vue/runtime-core@3.5.13':
resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==}
'@vue/runtime-dom@3.5.13':
resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==}
'@vue/server-renderer@3.5.13':
resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==}
peerDependencies:
vue: 3.5.13
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vue/tsconfig@0.7.0':
resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==}
peerDependencies:
typescript: 5.x
vue: ^3.4.0
peerDependenciesMeta:
typescript:
optional: true
vue:
optional: true
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
acorn@8.14.1:
resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
engines: {node: '>=0.4.0'}
hasBin: true
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.9.0:
resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
birpc@2.3.0:
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
element-plus@2.9.10:
resolution: {integrity: sha512-W2v9jWnm1kl/zm4bSvCh8aFCVlxvhG3fmqiDZwyd6WQiWGE595J/mpjcCggEr+49QDgIymhXrpPMOPPSARUbng==}
peerDependencies:
vue: ^3.2.0
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.25.4:
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
engines: {node: '>=18'}
hasBin: true
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
exsolve@1.0.5:
resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==}
fdir@6.4.4:
resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
immutable@5.1.2:
resolution: {integrity: sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
jiti@2.4.2:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
local-pkg@1.1.1:
resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==}
engines: {node: '>=14'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '*'
lodash-es: '*'
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.7.4:
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-fetch-native@1.6.6:
resolution: {integrity: sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
normalize.css@8.0.1:
resolution: {integrity: sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
ofetch@1.4.1:
resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==}
package-manager-detector@1.3.0:
resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
pinia@3.0.2:
resolution: {integrity: sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.1.0:
resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==}
postcss@8.5.3:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
qs@6.11.2:
resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==}
engines: {node: '>=0.6'}
quansync@0.2.10:
resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.40.2:
resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sass-embedded-android-arm64@1.88.0:
resolution: {integrity: sha512-YVdxVywlbXH74uomIcRsYLHF1644V+0per6YrfZndWicjfYnWqgbGq1xixdOzLxe3vac90RlsRNxTEb0VWlhmA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.88.0:
resolution: {integrity: sha512-jveGkHhHxJ2+GnNxl3OyhZAxR8YXJCSuj7JYzoVuFTxlsaFqFQwtUrvZro61xOVOrwfe8xMk2HE3ZEw6dolhBA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-ia32@1.88.0:
resolution: {integrity: sha512-6C4o+lGFsYcUPGtCvOdFhFLQl1rrcBUNuC4DILDayI4bZeh3Y2CjonzCT4VNKPsOm7LFGf0OKQAZm+3/oXVIKg==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [android]
sass-embedded-android-riscv64@1.88.0:
resolution: {integrity: sha512-zW1NmFHwPkBBg8wqVu8e5uCKeuTSk8vasB5BBEPvQubj4tWbgxrXGIVrQyseeGXJJQYSzjNiq3ua4qNoadBWJA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.88.0:
resolution: {integrity: sha512-b33Ja8sU67CcWCX9C3M+k8AcWXOb9uhyUJuKg/2hb/RhKUqBRCpMtQhsChpV7/DyXvyevLeosy28j673qNfnuQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.88.0:
resolution: {integrity: sha512-Zu+A4OzoFtZwTlcXn66ovZRTI9Ia610KJbtJBrpsXPfqR9QcCg7pPDB/zlPK5E5xFjsxGWaL0tICOifim1HCMg==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.88.0:
resolution: {integrity: sha512-nZ+/j5Z4llLejNyFcLUWJvbU3WNJDKiyZ7W+Hpn/52dDhzHiNWRVHH7humfzCEgLXZctPZlr56ubaNk/RsoSlA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.88.0:
resolution: {integrity: sha512-aphDl0Z4Y+YpPAqT0fEDDxZfrTXS/v36IRpGpVcbuRIua/iHd9L3wrZuwco1nbbY+sShFNiXPE1A9/k/ZGt8rw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.88.0:
resolution: {integrity: sha512-bjiTZ4MNvArReXgwnA56mT3i+vHH3BgkLQT3qVwRv6fVTPQpYopK8D/QzQKbrVGYKgzWPYzZfksSQFC9lzM2yA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-ia32@1.88.0:
resolution: {integrity: sha512-m+pQMD14JQeMlQ/J8vQxHXwAQPAcfLG034BQz05a8ahXmNrk9qJkrC7FLptDlhsJ6weldX54UvXceoSpw2VsxQ==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-arm64@1.88.0:
resolution: {integrity: sha512-Wxo9qklXqw+eYFHLo+uE9r9sbK/xklMt6xPU/HXs+ikoJcGtmugE7KRyyWeSfvPTi8jZvgfkFfNDZD9elzxEFA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.88.0:
resolution: {integrity: sha512-jGRZZYP8XOiE521Pep2u9ktx1FFkLHosjO7Dj/0pvjwUddBVT16jE40gv9pqtTynG0saD8jokqdkqJ+FM3NJzA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-ia32@1.88.0:
resolution: {integrity: sha512-utdTihiPCCP5HdKqwblQQWz864c7CqSplSGQ+p06GS+0ZfnuB/SKhtwe8fd11v4+IN8S2o0HAQ5KtWmRmk3eTA==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-riscv64@1.88.0:
resolution: {integrity: sha512-P8XB7QVSU8KJry4oxegzAnuFVWjbHc/JCHgF2ktq2dURVyxcaKDfQZtzbUgiPOKP/R6MZIFhXaJVJIhppcruEQ==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.88.0:
resolution: {integrity: sha512-OGEfD6AAm68vZTazFkIN7Dsu0ZQY983GZU+mWE9zZPLTIBzvNrrEZrEE/mpM6LemkwbqR+GaFP6rxGrkDz0Mhw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.88.0:
resolution: {integrity: sha512-3hBlfq4bXx0RkkNxvw/FPZSmUC1GMU8NE1Ef+2dJowxAeneRotHy5WXZIMKvH7NGpskf7U8ButK05U3OxPzrTA==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.88.0:
resolution: {integrity: sha512-FzM5mCxkFE20efDDSPO5N5o0ZKPqs51zowt2JAe5tdAzmy/jUQ0t515tph40dV2mfX0flBJgoou76gZKhylHGg==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-win32-arm64@1.88.0:
resolution: {integrity: sha512-Zp3yNEzk/gCCBIClQx8ihAGZ1YqPbjWjTnLWtruS9FcVrkrSAIjhqaesoN1Hy61aaIoiRektOyeffHH54jiQ3g==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-ia32@1.88.0:
resolution: {integrity: sha512-yUmD6BLb01ngw/gy+FcTdsCMFaoONGFYJcy6FhMr2OOcCHNjPVD+HqTF4ZRsLNbwna8PlP6XxHFzjPKzVw18xw==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [win32]
sass-embedded-win32-x64@1.88.0:
resolution: {integrity: sha512-j4pOP/S9vD4enRqbfwno07Xx+j0RkfVYGV31ZxzAIF+a1+3dDBlsbwgDNP68XemJx5SjpP8yM8the6nHAnMUiQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.88.0:
resolution: {integrity: sha512-GQUxgZFuej3NZ1TSPUHU8aebtYdnIeXqYsbNEEKBtE+SC7/Gr18KH1ijTAZHPw25OUfQCdtJaRy6Fo866dHmgw==}
engines: {node: '>=16.0.0'}
hasBin: true
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
sirv@3.0.1:
resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==}
engines: {node: '>=18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.2:
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
engines: {node: '>=16'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
sync-message-port@1.1.3:
resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==}
engines: {node: '>=16.0.0'}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
tinyglobby@0.2.13:
resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
engines: {node: '>=12.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
unconfig@7.3.2:
resolution: {integrity: sha512-nqG5NNL2wFVGZ0NA/aCFw0oJ2pxSf1lwg4Z5ill8wd7K4KX/rQbHlwbh+bjctXL5Ly1xtzHenHGOK0b+lG6JVg==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unocss@66.1.2:
resolution: {integrity: sha512-mVwuXzIZ5Ex83F4w3XVJyp9DSbh5KhDzglyvMLktX8oU0QxQtaSpa5lE1twl3wgM0pVL9gmzD4a0FoYWZuJIDg==}
engines: {node: '>=14'}
peerDependencies:
'@unocss/webpack': 66.1.2
vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0
peerDependenciesMeta:
'@unocss/webpack':
optional: true
vite:
optional: true
unplugin-utils@0.2.4:
resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==}
engines: {node: '>=18.12.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
vite@6.3.5:
resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: '>=1.21.0'
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-flow-layout@0.1.1:
resolution: {integrity: sha512-JdgRRUVrN0Y2GosA0M68DEbKlXMqJ7FQgsK8CjQD2vxvNSqAU6PZEpi4cfcTVtfM2GVOMjHo7GKKLbXxOBqDqA==}
peerDependencies:
vue: ^3.4.37
vue-router@4.5.1:
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
peerDependencies:
vue: ^3.2.0
vue-tsc@2.2.10:
resolution: {integrity: sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.13:
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.3.0
tinyexec: 1.0.1
'@antfu/utils@8.1.1': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/parser@7.27.2':
dependencies:
'@babel/types': 7.27.1
'@babel/types@7.27.1':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@bufbuild/protobuf@2.4.0': {}
'@ctrl/tinycolor@3.6.1': {}
'@element-plus/icons-vue@2.3.1(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue: 3.5.13(typescript@5.8.3)
'@esbuild/aix-ppc64@0.25.4':
optional: true
'@esbuild/android-arm64@0.25.4':
optional: true
'@esbuild/android-arm@0.25.4':
optional: true
'@esbuild/android-x64@0.25.4':
optional: true
'@esbuild/darwin-arm64@0.25.4':
optional: true
'@esbuild/darwin-x64@0.25.4':
optional: true
'@esbuild/freebsd-arm64@0.25.4':
optional: true
'@esbuild/freebsd-x64@0.25.4':
optional: true
'@esbuild/linux-arm64@0.25.4':
optional: true
'@esbuild/linux-arm@0.25.4':
optional: true
'@esbuild/linux-ia32@0.25.4':
optional: true
'@esbuild/linux-loong64@0.25.4':
optional: true
'@esbuild/linux-mips64el@0.25.4':
optional: true
'@esbuild/linux-ppc64@0.25.4':
optional: true
'@esbuild/linux-riscv64@0.25.4':
optional: true
'@esbuild/linux-s390x@0.25.4':
optional: true
'@esbuild/linux-x64@0.25.4':
optional: true
'@esbuild/netbsd-arm64@0.25.4':
optional: true
'@esbuild/netbsd-x64@0.25.4':
optional: true
'@esbuild/openbsd-arm64@0.25.4':
optional: true
'@esbuild/openbsd-x64@0.25.4':
optional: true
'@esbuild/sunos-x64@0.25.4':
optional: true
'@esbuild/win32-arm64@0.25.4':
optional: true
'@esbuild/win32-ia32@0.25.4':
optional: true
'@esbuild/win32-x64@0.25.4':
optional: true
'@floating-ui/core@1.7.0':
dependencies:
'@floating-ui/utils': 0.2.9
'@floating-ui/dom@1.7.0':
dependencies:
'@floating-ui/core': 1.7.0
'@floating-ui/utils': 0.2.9
'@floating-ui/utils@0.2.9': {}
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.1
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.1
mlly: 1.7.4
transitivePeerDependencies:
- supports-color
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@polka/url@1.0.0-next.29': {}
'@quansync/fs@0.1.3':
dependencies:
quansync: 0.2.10
'@rollup/rollup-android-arm-eabi@4.40.2':
optional: true
'@rollup/rollup-android-arm64@4.40.2':
optional: true
'@rollup/rollup-darwin-arm64@4.40.2':
optional: true
'@rollup/rollup-darwin-x64@4.40.2':
optional: true
'@rollup/rollup-freebsd-arm64@4.40.2':
optional: true
'@rollup/rollup-freebsd-x64@4.40.2':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.40.2':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-arm64-musl@4.40.2':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.40.2':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-x64-gnu@4.40.2':
optional: true
'@rollup/rollup-linux-x64-musl@4.40.2':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.40.2':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.40.2':
optional: true
'@rollup/rollup-win32-x64-msvc@4.40.2':
optional: true
'@sxzz/popperjs-es@2.11.7': {}
'@types/estree@1.0.7': {}
'@types/js-cookie@3.0.6': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.16
'@types/lodash@4.17.16': {}
'@types/node@22.15.17':
dependencies:
undici-types: 6.21.0
'@types/nprogress@0.2.3': {}
'@types/uuid@10.0.0': {}
'@types/web-bluetooth@0.0.16': {}
'@unocss/astro@66.1.2(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@unocss/core': 66.1.2
'@unocss/reset': 66.1.2
'@unocss/vite': 66.1.2(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))
optionalDependencies:
vite: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0)
transitivePeerDependencies:
- vue
'@unocss/cli@66.1.2':
dependencies:
'@ampproject/remapping': 2.3.0
'@unocss/config': 66.1.2
'@unocss/core': 66.1.2
'@unocss/preset-uno': 66.1.2
cac: 6.7.14
chokidar: 3.6.0
colorette: 2.0.20
consola: 3.4.2
magic-string: 0.30.17
pathe: 2.0.3
perfect-debounce: 1.0.0
tinyglobby: 0.2.13
unplugin-utils: 0.2.4
'@unocss/config@66.1.2':
dependencies:
'@unocss/core': 66.1.2
unconfig: 7.3.2
'@unocss/core@66.1.2': {}
'@unocss/extractor-arbitrary-variants@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/inspector@66.1.2(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@unocss/core': 66.1.2
'@unocss/rule-utils': 66.1.2
colorette: 2.0.20
gzip-size: 6.0.0
sirv: 3.0.1
vue-flow-layout: 0.1.1(vue@3.5.13(typescript@5.8.3))
transitivePeerDependencies:
- vue
'@unocss/postcss@66.1.2(postcss@8.5.3)':
dependencies:
'@unocss/config': 66.1.2
'@unocss/core': 66.1.2
'@unocss/rule-utils': 66.1.2
css-tree: 3.1.0
postcss: 8.5.3
tinyglobby: 0.2.13
'@unocss/preset-attributify@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-icons@66.1.2':
dependencies:
'@iconify/utils': 2.3.0
'@unocss/core': 66.1.2
ofetch: 1.4.1
transitivePeerDependencies:
- supports-color
'@unocss/preset-mini@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/extractor-arbitrary-variants': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/preset-tagify@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-typography@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-mini': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/preset-uno@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-wind3': 66.1.2
'@unocss/preset-web-fonts@66.1.2':
dependencies:
'@unocss/core': 66.1.2
ofetch: 1.4.1
'@unocss/preset-wind3@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-mini': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/preset-wind4@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/extractor-arbitrary-variants': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/preset-wind@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-wind3': 66.1.2
'@unocss/reset@66.1.2': {}
'@unocss/rule-utils@66.1.2':
dependencies:
'@unocss/core': 66.1.2
magic-string: 0.30.17
'@unocss/transformer-attributify-jsx@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/transformer-compile-class@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/transformer-directives@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/rule-utils': 66.1.2
css-tree: 3.1.0
'@unocss/transformer-variant-group@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/vite@66.1.2(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@unocss/config': 66.1.2
'@unocss/core': 66.1.2
'@unocss/inspector': 66.1.2(vue@3.5.13(typescript@5.8.3))
chokidar: 3.6.0
magic-string: 0.30.17
pathe: 2.0.3
tinyglobby: 0.2.13
unplugin-utils: 0.2.4
vite: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0)
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue@5.2.4(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))':
dependencies:
vite: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0)
vue: 3.5.13(typescript@5.8.3)
'@volar/language-core@2.4.13':
dependencies:
'@volar/source-map': 2.4.13
'@volar/source-map@2.4.13': {}
'@volar/typescript@2.4.13':
dependencies:
'@volar/language-core': 2.4.13
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.27.2
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.13':
dependencies:
'@vue/compiler-core': 3.5.13
'@vue/shared': 3.5.13
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.27.2
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.17
postcss: 8.5.3
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.13':
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.6':
dependencies:
'@vue/devtools-kit': 7.7.6
'@vue/devtools-kit@7.7.6':
dependencies:
'@vue/devtools-shared': 7.7.6
birpc: 2.3.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-shared@7.7.6':
dependencies:
rfdc: 1.4.1
'@vue/language-core@2.2.10(typescript@5.8.3)':
dependencies:
'@volar/language-core': 2.4.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.13
alien-signals: 1.0.13
minimatch: 9.0.5
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.8.3
'@vue/reactivity@3.5.13':
dependencies:
'@vue/shared': 3.5.13
'@vue/runtime-core@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/shared': 3.5.13
'@vue/runtime-dom@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/runtime-core': 3.5.13
'@vue/shared': 3.5.13
csstype: 3.1.3
'@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
vue: 3.5.13(typescript@5.8.3)
'@vue/shared@3.5.13': {}
'@vue/tsconfig@0.7.0(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))':
optionalDependencies:
typescript: 5.8.3
vue: 3.5.13(typescript@5.8.3)
'@vueuse/core@9.13.0(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.5.13(typescript@5.8.3))
vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@9.13.0': {}
'@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
acorn@8.14.1: {}
alien-signals@1.0.13: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
async-validator@4.2.5: {}
asynckit@0.4.0: {}
axios@1.9.0:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
birpc@2.3.0: {}
brace-expansion@2.0.1:
dependencies:
balanced-match: 1.0.2
braces@3.0.3:
dependencies:
fill-range: 7.1.1
buffer-builder@0.2.0: {}
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
colorette@2.0.20: {}
colorjs.io@0.5.2: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
confbox@0.1.8: {}
confbox@0.2.2: {}
consola@3.4.2: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
csstype@3.1.3: {}
dayjs@1.11.13: {}
de-indent@1.0.2: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
defu@6.1.4: {}
delayed-stream@1.0.0: {}
destr@2.0.5: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
duplexer@0.1.2: {}
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
element-plus@2.9.10(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.3.1(vue@3.5.13(typescript@5.8.3))
'@floating-ui/dom': 1.7.0
'@popperjs/core': '@sxzz/popperjs-es@2.11.7'
'@types/lodash': 4.17.16
'@types/lodash-es': 4.17.12
'@vueuse/core': 9.13.0(vue@3.5.13(typescript@5.8.3))
async-validator: 4.2.5
dayjs: 1.11.13
escape-html: 1.0.3
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies:
- '@vue/composition-api'
entities@4.5.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.25.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.4
'@esbuild/android-arm': 0.25.4
'@esbuild/android-arm64': 0.25.4
'@esbuild/android-x64': 0.25.4
'@esbuild/darwin-arm64': 0.25.4
'@esbuild/darwin-x64': 0.25.4
'@esbuild/freebsd-arm64': 0.25.4
'@esbuild/freebsd-x64': 0.25.4
'@esbuild/linux-arm': 0.25.4
'@esbuild/linux-arm64': 0.25.4
'@esbuild/linux-ia32': 0.25.4
'@esbuild/linux-loong64': 0.25.4
'@esbuild/linux-mips64el': 0.25.4
'@esbuild/linux-ppc64': 0.25.4
'@esbuild/linux-riscv64': 0.25.4
'@esbuild/linux-s390x': 0.25.4
'@esbuild/linux-x64': 0.25.4
'@esbuild/netbsd-arm64': 0.25.4
'@esbuild/netbsd-x64': 0.25.4
'@esbuild/openbsd-arm64': 0.25.4
'@esbuild/openbsd-x64': 0.25.4
'@esbuild/sunos-x64': 0.25.4
'@esbuild/win32-arm64': 0.25.4
'@esbuild/win32-ia32': 0.25.4
'@esbuild/win32-x64': 0.25.4
escape-html@1.0.3: {}
estree-walker@2.0.2: {}
exsolve@1.0.5: {}
fdir@6.4.4(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
follow-redirects@1.15.9: {}
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
globals@15.15.0: {}
gopd@1.2.0: {}
gzip-size@6.0.0:
dependencies:
duplexer: 0.1.2
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hookable@5.5.3: {}
immutable@5.1.2: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
is-what@4.1.16: {}
jiti@2.4.2: {}
js-cookie@3.0.5: {}
kolorist@1.8.0: {}
local-pkg@1.1.1:
dependencies:
mlly: 1.7.4
pkg-types: 2.1.0
quansync: 0.2.10
lodash-es@4.17.21: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.17.21
lodash-es: 4.17.21
lodash@4.17.21: {}
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
math-intrinsics@1.1.0: {}
mdn-data@2.12.2: {}
memoize-one@6.0.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.1
mitt@3.0.1: {}
mlly@1.7.4:
dependencies:
acorn: 8.14.1
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
mrmime@2.0.1: {}
ms@2.1.3: {}
muggle-string@0.4.1: {}
nanoid@3.3.11: {}
node-fetch-native@1.6.6: {}
normalize-path@3.0.0: {}
normalize-wheel-es@1.2.0: {}
normalize.css@8.0.1: {}
nprogress@0.2.0: {}
object-inspect@1.13.4: {}
ofetch@1.4.1:
dependencies:
destr: 2.0.5
node-fetch-native: 1.6.6
ufo: 1.6.1
package-manager-detector@1.3.0: {}
path-browserify@1.0.1: {}
path-to-regexp@3.3.0: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@4.0.2: {}
pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@vue/devtools-api': 7.7.6
vue: 3.5.13(typescript@5.8.3)
optionalDependencies:
typescript: 5.8.3
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.7.4
pathe: 2.0.3
pkg-types@2.1.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.5
pathe: 2.0.3
postcss@8.5.3:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
proxy-from-env@1.1.0: {}
qs@6.11.2:
dependencies:
side-channel: 1.1.0
quansync@0.2.10: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
rfdc@1.4.1: {}
rollup@4.40.2:
dependencies:
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.40.2
'@rollup/rollup-android-arm64': 4.40.2
'@rollup/rollup-darwin-arm64': 4.40.2
'@rollup/rollup-darwin-x64': 4.40.2
'@rollup/rollup-freebsd-arm64': 4.40.2
'@rollup/rollup-freebsd-x64': 4.40.2
'@rollup/rollup-linux-arm-gnueabihf': 4.40.2
'@rollup/rollup-linux-arm-musleabihf': 4.40.2
'@rollup/rollup-linux-arm64-gnu': 4.40.2
'@rollup/rollup-linux-arm64-musl': 4.40.2
'@rollup/rollup-linux-loongarch64-gnu': 4.40.2
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.2
'@rollup/rollup-linux-riscv64-gnu': 4.40.2
'@rollup/rollup-linux-riscv64-musl': 4.40.2
'@rollup/rollup-linux-s390x-gnu': 4.40.2
'@rollup/rollup-linux-x64-gnu': 4.40.2
'@rollup/rollup-linux-x64-musl': 4.40.2
'@rollup/rollup-win32-arm64-msvc': 4.40.2
'@rollup/rollup-win32-ia32-msvc': 4.40.2
'@rollup/rollup-win32-x64-msvc': 4.40.2
fsevents: 2.3.3
rxjs@7.8.2:
dependencies:
tslib: 2.3.0
sass-embedded-android-arm64@1.88.0:
optional: true
sass-embedded-android-arm@1.88.0:
optional: true
sass-embedded-android-ia32@1.88.0:
optional: true
sass-embedded-android-riscv64@1.88.0:
optional: true
sass-embedded-android-x64@1.88.0:
optional: true
sass-embedded-darwin-arm64@1.88.0:
optional: true
sass-embedded-darwin-x64@1.88.0:
optional: true
sass-embedded-linux-arm64@1.88.0:
optional: true
sass-embedded-linux-arm@1.88.0:
optional: true
sass-embedded-linux-ia32@1.88.0:
optional: true
sass-embedded-linux-musl-arm64@1.88.0:
optional: true
sass-embedded-linux-musl-arm@1.88.0:
optional: true
sass-embedded-linux-musl-ia32@1.88.0:
optional: true
sass-embedded-linux-musl-riscv64@1.88.0:
optional: true
sass-embedded-linux-musl-x64@1.88.0:
optional: true
sass-embedded-linux-riscv64@1.88.0:
optional: true
sass-embedded-linux-x64@1.88.0:
optional: true
sass-embedded-win32-arm64@1.88.0:
optional: true
sass-embedded-win32-ia32@1.88.0:
optional: true
sass-embedded-win32-x64@1.88.0:
optional: true
sass-embedded@1.88.0:
dependencies:
'@bufbuild/protobuf': 2.4.0
buffer-builder: 0.2.0
colorjs.io: 0.5.2
immutable: 5.1.2
rxjs: 7.8.2
supports-color: 8.1.1
sync-child-process: 1.0.2
varint: 6.0.0
optionalDependencies:
sass-embedded-android-arm: 1.88.0
sass-embedded-android-arm64: 1.88.0
sass-embedded-android-ia32: 1.88.0
sass-embedded-android-riscv64: 1.88.0
sass-embedded-android-x64: 1.88.0
sass-embedded-darwin-arm64: 1.88.0
sass-embedded-darwin-x64: 1.88.0
sass-embedded-linux-arm: 1.88.0
sass-embedded-linux-arm64: 1.88.0
sass-embedded-linux-ia32: 1.88.0
sass-embedded-linux-musl-arm: 1.88.0
sass-embedded-linux-musl-arm64: 1.88.0
sass-embedded-linux-musl-ia32: 1.88.0
sass-embedded-linux-musl-riscv64: 1.88.0
sass-embedded-linux-musl-x64: 1.88.0
sass-embedded-linux-riscv64: 1.88.0
sass-embedded-linux-x64: 1.88.0
sass-embedded-win32-arm64: 1.88.0
sass-embedded-win32-ia32: 1.88.0
sass-embedded-win32-x64: 1.88.0
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.0
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
sirv@3.0.1:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.2:
dependencies:
copy-anything: 3.0.5
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.1.3
sync-message-port@1.1.3: {}
tinyexec@1.0.1: {}
tinyglobby@0.2.13:
dependencies:
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
totalist@3.0.1: {}
tslib@2.3.0: {}
typescript@5.8.3: {}
ufo@1.6.1: {}
unconfig@7.3.2:
dependencies:
'@quansync/fs': 0.1.3
defu: 6.1.4
jiti: 2.4.2
quansync: 0.2.10
undici-types@6.21.0: {}
unocss@66.1.2(postcss@8.5.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@unocss/astro': 66.1.2(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))
'@unocss/cli': 66.1.2
'@unocss/core': 66.1.2
'@unocss/postcss': 66.1.2(postcss@8.5.3)
'@unocss/preset-attributify': 66.1.2
'@unocss/preset-icons': 66.1.2
'@unocss/preset-mini': 66.1.2
'@unocss/preset-tagify': 66.1.2
'@unocss/preset-typography': 66.1.2
'@unocss/preset-uno': 66.1.2
'@unocss/preset-web-fonts': 66.1.2
'@unocss/preset-wind': 66.1.2
'@unocss/preset-wind3': 66.1.2
'@unocss/preset-wind4': 66.1.2
'@unocss/transformer-attributify-jsx': 66.1.2
'@unocss/transformer-compile-class': 66.1.2
'@unocss/transformer-directives': 66.1.2
'@unocss/transformer-variant-group': 66.1.2
'@unocss/vite': 66.1.2(vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0))(vue@3.5.13(typescript@5.8.3))
optionalDependencies:
vite: 6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0)
transitivePeerDependencies:
- postcss
- supports-color
- vue
unplugin-utils@0.2.4:
dependencies:
pathe: 2.0.3
picomatch: 4.0.2
uuid@11.1.0: {}
varint@6.0.0: {}
vite@6.3.5(@types/node@22.15.17)(jiti@2.4.2)(sass-embedded@1.88.0):
dependencies:
esbuild: 0.25.4
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
postcss: 8.5.3
rollup: 4.40.2
tinyglobby: 0.2.13
optionalDependencies:
'@types/node': 22.15.17
fsevents: 2.3.3
jiti: 2.4.2
sass-embedded: 1.88.0
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.8.3)):
dependencies:
vue: 3.5.13(typescript@5.8.3)
vue-flow-layout@0.1.1(vue@3.5.13(typescript@5.8.3)):
dependencies:
vue: 3.5.13(typescript@5.8.3)
vue-router@4.5.1(vue@3.5.13(typescript@5.8.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.8.3)
vue-tsc@2.2.10(typescript@5.8.3):
dependencies:
'@volar/typescript': 2.4.13
'@vue/language-core': 2.2.10(typescript@5.8.3)
typescript: 5.8.3
vue@3.5.13(typescript@5.8.3):
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/compiler-sfc': 3.5.13
'@vue/runtime-dom': 3.5.13
'@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.3))
'@vue/shared': 3.5.13
optionalDependencies:
typescript: 5.8.3
zrender@5.6.1:
dependencies:
tslib: 2.3.0
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
VITE_APP_BASE_API = '/api'
<script setup lang="ts">
// App.vue
</script>
<template>
<router-view />
</template>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden; /* 防止出现滚动条 */
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}
#app {
height: 100%;
width: 100%;
overflow: hidden; /* 修改为hidden,防止横向滚动条 */
}
.app-container {
padding: 20px;
box-sizing: border-box; /* 确保padding不会增加元素宽度 */
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
// API路径配置文件
// 这个文件确保所有API路径在一处统一管理,方便修改
export const authApi = {
login: '/auth/login',
logout: '/auth/logout'
} as const
export const scrapydApi = {
// scrapyd相关接口
listProjects: '/scrapyd/listProjects',
listVersions: '/scrapyd/listVersions',
listSpiders: '/scrapyd/listSpiders',
listJobsMerge: '/scrapyd/listJobsMerge',
cancel: '/scrapyd/cancel',
cancelAllJob: '/scrapyd/cancelAllJob',
deleteVersion: '/scrapyd/deleteVersion',
deleteProject: '/scrapyd/deleteProject',
schedule: '/scrapyd/schedule',
daemonStatus: '/scrapyd/daemonStatus',
addVersion: '/scrapyd/addVersion',
// 日志相关
logs: '/scrapyd/logs',
projectLogs: '/scrapyd/projectLogs',
spiderLogs: '/scrapyd/spiderLogs',
jobLog: '/scrapyd/jobLog'
} as const
export const scheduleApi = {
// 调度任务相关
addJob: '/schedule/addJob',
getJobs: '/schedule/getJobs',
pauseJob: '/schedule/pauseJob',
resumeJob: '/schedule/resumeJob',
removeJob: '/schedule/removeJob',
jobDetail: '/schedule/jobDetail',
state: '/schedule/state',
start: '/schedule/start',
shutdown: '/schedule/shutdown',
pause: '/schedule/pause',
resume: '/schedule/resume',
removeAllJobs: '/schedule/removeAllJobs',
scheduleLogs: '/schedule/scheduleLogs',
removeScheduleLogs: '/schedule/removeScheduleLogs',
} as const
export const scrapydServerApi = {
getScrapydServerPage: '/scrapydServer/getScrapydServerPage',
addScrapydServer: '/scrapydServer/addScrapydServer',
updateScrapydServer: '/scrapydServer/updateScrapydServer',
updateScrapydServerStatus: '/scrapydServer/updateScrapydServerStatus',
deleteScrapydServer: '/scrapydServer/deleteScrapydServer'
} as const
export const systemApi = {
// 系统相关
systemInfo: '/system/systemInfo',
systemData: '/system/systemData',
systemConfig: '/system/systemConfig',
loginHistoryList: '/actionHistory/loginHistoryList',
scrapydServerList: '/system/scrapydServerList' // 没用
} as const
export const statsApi = {
// 统计相关
statsList: '/statsCollection/listItem',
statsDetail: '/statsCollection/detail',
removeStats: '/statsCollection/delete',
clearAllStats: '/statsCollection/clearAll' // TODO 未实现
} as const
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scrapydApi } from './apiPaths'
// 获取日志列表
export function getLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.logs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目日志
export function getProjectLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.projectLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫日志
export function getSpiderLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.spiderLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取任务日志
export function getJobLog(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.jobLog,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scrapydApi } from './apiPaths'
// 获取项目列表
export function getProjectList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listProjects,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目版本列表
export function getProjectVersions(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listVersions,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫列表
export function getSpiderList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取任务列表
export function getJobList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listJobsMerge,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 取消任务
export function cancelJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.cancel,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 取消所有任务
export function cancelAllJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.cancelAllJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除项目版本
export function deleteProjectVersion(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.deleteVersion,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除项目
export function deleteProject(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.deleteProject,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 调度任务
export function scheduleJob(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.schedule,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 运行爬虫
export function schedule(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.schedule,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取服务状态
export function getDaemonStatus(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.daemonStatus,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 添加项目版本
export function addProjectVersion(data: FormData): Promise<ApiResponse> {
return request({
url: scrapydApi.addVersion,
method: POST,
data,
headers: {
'Content-Type': 'multipart/form-data'
}
}) as unknown as Promise<ApiResponse>
}
// 获取项目下的爬虫列表
export function listSpiders(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫列表简化版(用于表单选择等)
export function getSpiders(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目列表简化版(用于表单选择等)
export function listProjects(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listProjects,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scheduleApi, scrapydServerApi } from './apiPaths'
// 添加调度任务
export function addJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.addJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度任务列表
export function getJobs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scheduleApi.getJobs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 暂停调度任务
export function pauseJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.pauseJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 恢复调度任务
export function resumeJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.resumeJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除调度任务
export function removeJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.removeJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度任务详情
export function getJobDetail(data: QueryParams): Promise<ApiResponse> {
return request({
url: scheduleApi.jobDetail,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度器状态
export function getScheduleState(): Promise<ApiResponse> {
return request({
url: scheduleApi.state,
method: POST,
}) as unknown as Promise<ApiResponse>
}
// 启动调度器
export function startSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.start,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 关闭调度器
export function shutdownSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.shutdown,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 暂停调度器
export function pauseSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.pause,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 恢复调度器
export function resumeSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.resume,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 删除所有调度任务
export function removeAllJobs(): Promise<ApiResponse> {
return request({
url: scheduleApi.removeAllJobs,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 获取调度日志
export function getScheduleLogs(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.scheduleLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除调度日志
export function removeScheduleLogs(data: Record<string, any> = {}): Promise<ApiResponse> {
return request({
url: scheduleApi.removeScheduleLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取Scrapyd服务列表
export function getScrapydServerPage(): Promise<ApiResponse> {
return request({
url: scrapydServerApi.getScrapydServerPage,
method: POST,
}) as unknown as Promise<ApiResponse>
}
import { type ApiResponse, request, POST } from "@/utils/request"
import { scrapydServerApi } from "./apiPaths"
// 添加Scrapyd服务
export const addScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.addScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 更新Scrapyd服务
export const updateScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.updateScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 更新Scrapyd服务状态
export const updateScrapydServerStatus = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.updateScrapydServerStatus,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除Scrapyd服务
export const deleteScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.deleteScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { statsApi } from './apiPaths'
// 获取爬虫统计数据列表
export function getStatsList(params: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.statsList,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫统计数据明细
export function getStatsDetail(params: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.statsDetail,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
// 删除爬虫统计数据
export function removeStats(data: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.removeStats,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 清空爬虫统计数据
export function clearAllStats(data: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.clearAllStats, // TODO 这个接口在后端还没有实现
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { systemApi, scrapydServerApi } from './apiPaths'
// 获取系统信息
export function getSystemInfo(): Promise<ApiResponse> {
return request({
url: systemApi.systemInfo,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 获取系统数据
export function getSystemData(data: QueryParams): Promise<ApiResponse> {
return request({
url: systemApi.systemData,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取系统配置
export function getSystemConfig(): Promise<ApiResponse> {
return request({
url: systemApi.systemConfig,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 更新系统配置
export function updateSystemConfig(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: systemApi.systemConfig,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取登录历史
export function getLoginHistory(params: QueryParams): Promise<ApiResponse> {
return request({
url: systemApi.loginHistoryList,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
export const getScrapydServerList = (): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.getScrapydServerPage,
method: POST,
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { authApi } from './apiPaths'
// 用户登录
export function login(data: { username: string, password: string }): Promise<ApiResponse> {
return request({
url: authApi.login,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取用户信息
export function getInfo() {
return new Promise(resolve => {
resolve({
data: {
roles: ['admin'],
introduction: 'Admin',
avatar: 'user-avatar.gif',
name: 'Admin'
}
})
})
}
// 用户登出
export function logout(): Promise<ApiResponse> {
return request({
url: authApi.logout,
method: POST
}) as unknown as Promise<ApiResponse>
}
<svg width="252" height="294" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M0 57.732L126.099 0l126.099 57.732-126.099 57.732z" fill="#EAEAEA"/>
<path d="M126.099 115.464v116.608L0 174.339V57.732l126.099 57.732z" fill="#D8D8D8"/>
<path d="M126.099 115.464v116.608l126.099-57.733V57.732l-126.099 57.732z" fill="#ABABAB"/>
<path d="M126.099 247.186V294l126.099-57.732v-46.816l-126.099 57.734z" fill="#ABABAB"/>
<path d="M126.099 247.186V294L0 236.268v-46.816l126.099 57.734z" fill="#D8D8D8"/>
<path d="M0 189.453L126.099 247.186l126.099-57.733-126.099-57.732z" fill="#EAEAEA"/>
</g>
</svg>
<svg width="100" height="75" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M0 0h100v75H0z"/>
<path d="M16.4 28.6c0-7.8 6.3-14.2 14.2-14.2 7.8 0 14.2 6.3 14.2 14.2 0 5-2.5 9.3-6.4 11.7 0 0-1.3.7-1.5 2.2-.1.6-.2 1.9-.2 1.9h-12.1s-.1-1.4-.2-2c-.3-1.5-1.6-2.1-1.6-2.1-3.9-2.4-6.4-6.7-6.4-11.7" fill="#FFF"/>
<path d="M73 28.6c0-7.8 6.3-14.2 14.2-14.2 7.8 0 14.2 6.3 14.2 14.2 0 5-2.5 9.3-6.4 11.7 0 0-1.3.7-1.5 2.2-.1.6-.2 1.9-.2 1.9H81.2s-.1-1.4-.2-2c-.3-1.5-1.6-2.1-1.6-2.1-3.9-2.4-6.4-6.7-6.4-11.7" fill="#FFF"/>
<path d="M30.6 44.4h42.8s1.9 1.3 1.9 4v2.1H28.8v-2c0-3 1.8-4.1 1.8-4.1" fill="#FFF"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
\ No newline at end of file
<template>
<div class="active-plate">
<el-row :gutter="20">
<el-col :xs="12" :sm="8" :md="8" :lg="6" v-for="(item, index) in infoList" :key="index" @click="handleClick(item)">
<div class="info-card" :style="{ borderColor: item.color }">
<div class="icon-box" :style="{ background: item.color }">
<el-icon :size="24" :color="item.color === '#ffffff' ? '#333' : '#fff'">
<component :is="item.icon" />
</el-icon>
</div>
<div class="content">
<div class="count">{{ item.count }}</div>
<div class="title">{{ item.title }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
interface InfoItem {
icon: any
title: string
count: number | string
color: string
}
defineProps({
infoList: {
type: Array as () => InfoItem[],
default: () => []
}
})
const handleClick = (item: InfoItem) => {
// 处理点击事件
console.log('Clicked item:', item)
}
</script>
<style lang="scss" scoped>
.active-plate {
.info-card {
display: flex;
padding: 15px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #ddd;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
}
.icon-box {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.count {
font-size: 24px;
font-weight: 500;
line-height: 1.2;
color: #333;
}
.title {
font-size: 14px;
color: #909399;
margin-top: 5px;
}
}
}
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { isExternal } from '@/utils/validate'
const props = defineProps({
to: {
type: String,
required: true
}
})
const isExtLink = computed(() => isExternal(props.to))
</script>
<template>
<a v-if="isExtLink" :href="to" target="_blank" rel="noopener">
<slot />
</a>
<router-link v-else :to="to">
<slot />
</router-link>
</template>
<template>
<span>
<el-button
@click="changeAutoRefresh"
:size="size"
>{{ autoRefreshText }}</el-button> <span class="tips">{{ countDownText }}</span>
</span>
</template>
<script setup lang="ts">
/**
* 自动刷新组件
* emit: refresh - 刷新事件
*/
import { ref, computed, onBeforeUnmount } from 'vue';
const props = defineProps({
// 刷新频率,默认:3秒
frequency: { type: Number, default: 3000 },
// 按钮大小
size: { type: String, default: 'default' }
});
const emit = defineEmits(['refresh']);
// 定时器
const timer = ref<NodeJS.Timeout | null>(null);
const countDownTimer = ref<NodeJS.Timeout | null>(null);
// 倒计时
const countDown = ref(0);
const autoRefreshText = computed(() => {
if (timer.value == null) {
return '开启自动刷新';
} else {
return '关闭自动刷新';
}
});
const countDownText = computed(() => {
if (timer.value == null) {
return '';
} else {
return countDown.value + ' 秒后自动刷新';
}
});
function autoRefresh() {
emit('refresh');
resetCountDown();
}
function countDownReduce() {
countDown.value--;
}
function resetCountDown() {
countDown.value = Math.ceil(props.frequency * 0.001);
}
function openAutoRefresh() {
resetCountDown();
// 启动读秒计时器
countDownTimer.value = setInterval(() => {
countDownReduce();
}, 1000);
// 启动自动刷新计时器
timer.value = setInterval(() => {
autoRefresh();
}, props.frequency);
}
function closeAutoRefresh() {
if (timer.value) clearInterval(timer.value);
if (countDownTimer.value) clearInterval(countDownTimer.value);
timer.value = null;
countDownTimer.value = null;
}
function changeAutoRefresh() {
if (timer.value) {
closeAutoRefresh();
} else {
openAutoRefresh();
}
}
// 组件销毁前清除定时器
onBeforeUnmount(() => {
closeAutoRefresh();
});
</script>
<style lang="scss" scoped>
.tips {
margin-left: 10px;
color: #666666;
font-size: 12px;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, type RouteLocationMatched } from 'vue-router'
// 当路由有redirect时,面包屑不显示这条路由
const isDashboard = (route: RouteLocationMatched): boolean => {
const name = route && route.name
if (!name) {
return false
}
return name.toString().trim().toLowerCase() === 'Home'.toLowerCase()
}
const getBreadcrumb = (route: RouteLocationMatched): { name: string, redirect?: string } => {
if (route.meta?.title) {
return { name: route.meta.title as string, redirect: route.redirect as string | undefined }
}
return { name: route.name as string }
}
const route = useRoute()
const levelList = ref<{ name: string, path?: string }[]>([])
const getBreadcrumbs = () => {
// 只显示有meta.title且不是隐藏的路由
const matched = route.matched.filter(item => item.meta && item.meta.title && !item.meta.hidden)
// 如果第一条不是首页则添加首页
if (matched.length > 0 && !isDashboard(matched[0])) {
matched.unshift({
path: '/',
name: 'Home',
meta: { title: '首页' }
} as unknown as RouteLocationMatched)
}
levelList.value = matched.map(item => {
const bread = getBreadcrumb(item)
return { name: bread.name, path: bread.redirect || item.path }
})
}
getBreadcrumbs()
// 监听路由变化
watch(
() => route.path,
() => {
getBreadcrumbs()
}
)
</script>
<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="index === levelList.length - 1" class="no-redirect">{{ item.name }}</span>
<router-link v-else :to="item.path!">{{ item.name }}</router-link>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle-click'])
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const isCollapse = computed(() => {
return !sidebar.value.opened
})
const toggleClick = () => {
emit('toggle-click')
}
</script>
<template>
<div class="hamburger-container" @click="toggleClick">
<el-icon :class="{'is-active': isCollapse}" class="hamburger-icon">
<Menu />
</el-icon>
</div>
</template>
<style lang="scss" scoped>
.hamburger-container {
padding: 0 15px;
height: 100%;
float: left;
cursor: pointer;
transition: background 0.3s;
display: flex;
align-items: center;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.hamburger-icon {
font-size: 20px;
transform: rotate(0deg);
transition: transform 0.3s ease;
&.is-active {
transform: rotate(90deg);
}
}
}
</style>
<template>
<div class="select-project">
<el-select
v-model="currentValue"
placeholder="选择项目"
@change="handleChange"
:size="size"
style="width: 300px;"
clearable
>
<el-option
v-for="item in projectList"
:key="item.project"
:label="item.project"
:value="item.project"
/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import * as projectApi from '@/api/project';
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
},
value: {
type: String,
default: ''
},
size: {
type: String,
default: 'default'
}
});
const emit = defineEmits(['change', 'update:value', 'on-init']);
const projectList = ref<any[]>([]);
const currentValue = ref('');
// 获取项目列表
const getProjectList = async () => {
if (!props.scrapydServerId) {
return;
}
try {
const res = await projectApi.getProjectList({
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
projectList.value = res.data || [];
emit('on-init', projectList.value);
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
// 重置数据
const resetData = () => {
getProjectList();
};
// 处理选择变化
const handleChange = (value: string) => {
emit('update:value', value);
emit('change', value);
};
// 监听服务器ID变化
watch(() => props.scrapydServerId, (newVal) => {
if (newVal) {
resetData();
}
});
// 监听value变化
watch(() => props.value, (newVal) => {
currentValue.value = newVal;
});
onMounted(() => {
currentValue.value = props.value;
getProjectList();
});
// 对外暴露方法
defineExpose({
resetData
});
</script>
<style lang="scss" scoped>
.select-project {
display: inline-block;
}
</style>
<template>
<div class="text-left">
<el-form-item label="Scrapyd服务器">
<el-select
v-model="currentValue"
placeholder="请选择Scrapyd服务器"
style="width: 300px"
@change="handleChange"
clearable
size="default"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import * as systemApi from '@/api/system'
const props = defineProps({
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['change', 'update:value', 'on-init'])
interface OptionItem {
label: string
value: string
}
const options = ref<OptionItem[]>([])
const currentValue = ref('')
// 获取Scrapyd服务器列表
const getScrapydServerList = async () => {
try {
const res = await systemApi.getScrapydServerList()
if (res.code === 0) {
options.value = res.data.list.map((item: any) => ({
label: item.server_name,
value: item.id
}))
// 设置初始值
if (options.value.length > 0) {
// 如果没有当前值,或者当前值在选项中不存在,则设置为第一个选项的值
const shouldSetDefault = !currentValue.value || !options.value.some(opt => opt.value === currentValue.value)
if (shouldSetDefault) {
currentValue.value = options.value[0].value
emit('update:value', currentValue.value)
emit('change', currentValue.value)
}
}
emit('on-init', options.value)
}
} catch (error) {
console.error('获取Scrapyd服务器列表失败:', error)
}
}
// 选择变更处理
const handleChange = (val: string) => {
emit('update:value', val)
emit('change', val)
}
// 监听props.value变化
watch(() => props.value, (newVal) => {
currentValue.value = newVal
})
onMounted(() => {
currentValue.value = props.value
getScrapydServerList()
})
</script>
export const ScheduleTypeEnum = {
// 指定一个服务器
ONLY_ONE_SERVER: '0',
// 随机轮询
RANDOM_SERVER: '1',
}
export const ScheduleTypeOptions = [
{
value: ScheduleTypeEnum.ONLY_ONE_SERVER,
label: '当前服务器',
},
{
value: ScheduleTypeEnum.RANDOM_SERVER,
label: '服务器轮询',
},
]
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const key = computed(() => route.path)
</script>
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<router-view :key="key" />
</transition>
</section>
</template>
<style scoped>
.app-main {
/*50 = navbar */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow-y: auto; /* 修改为auto允许垂直滚动 */
overflow-x: hidden; /* 防止水平滚动 */
}
.fixed-header+.app-main {
padding-top: 50px;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
import Breadcrumb from '@/components/Breadcrumb/index.vue'
import Hamburger from '@/components/Hamburger/index.vue'
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const avatar = computed(() => userStore.avatar || 'user-avatar.gif')
const toggleSideBar = () => {
appStore.toggleSidebar()
}
const logout = async () => {
await userStore.logout()
router.push(`/login?redirect=${router.currentRoute.value.fullPath}`)
}
</script>
<template>
<div class="navbar">
<hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb class="breadcrumb-container" />
<div class="right-menu">
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
<el-icon><CaretBottom /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item>
首页
</el-dropdown-item>
</router-link>
<el-dropdown-item divided @click="logout">
<span style="display:block;">退出</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
float: left;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon {
cursor: pointer;
position: absolute;
right: -20px;
top: 15px;
font-size: 12px;
}
}
}
}
}
</style>
<script setup lang="ts">
import settings from '@/settings'
const props = defineProps({
collapse: {
type: Boolean,
required: true
}
})
const title = settings.title || 'fk spider web'
// Logo 图片可在后续使用实际的 logo 文件
// const logo = require('@/assets/logo.png')
const logo = '' // 暂时为空
</script>
<template>
<div class="sidebar-logo-container" :class="{'collapse': collapse}">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="logo">
<h1 v-else class="sidebar-title">{{ title }}</h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="logo">
<h1 class="sidebar-title">{{ title }}</h1>
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.sidebar-logo-container {
position: relative;
width: 100%;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter-from,
.sidebarLogoFade-leave-to {
opacity: 0;
}
</style>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PropType } from 'vue'
import { isExternal } from '@/utils/validate'
import AppLink from '@/components/AppLink/index.vue'
// 使用类型断言来处理路由项
type MenuItem = any
const props = defineProps({
item: {
type: Object as PropType<MenuItem>,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
})
// 定义要显示的唯一子项
const onlyOneChild = ref<MenuItem | null>(null)
// 是否总是显示根菜单
const alwaysShowRootMenu = computed(() => {
return props.item.meta?.alwaysShow || false
})
// 解析路径
const resolvePath = (routePath: string) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return props.basePath ? props.basePath + '/' + routePath : routePath
}
// 是否只有一个要显示的子项
const hasOneShowingChild = (children: MenuItem[] = [], parent: MenuItem): boolean => {
if (!children) {
children = []
}
const showingChildren = children.filter(item => {
if (item.meta?.hidden) {
return false
} else {
// 临时设置(当只有一个子路由时使用)
onlyOneChild.value = item
return true
}
})
// 当只有一个子路由时,默认显示该子路由
if (showingChildren.length === 1) {
return true
}
// 当没有要显示的子路由时,则显示父路由
if (showingChildren.length === 0) {
onlyOneChild.value = {
...parent,
path: '',
meta: parent.meta
}
return true
}
return false
}
</script>
<template>
<div v-if="!item.meta?.hidden" class="sidebar-item-container">
<template v-if="hasOneShowingChild(item.children as MenuItem[], item) && (!onlyOneChild?.children?.length || onlyOneChild?.meta?.noShowingChildren) && !alwaysShowRootMenu">
<app-link
v-if="onlyOneChild?.meta"
:to="resolvePath(onlyOneChild.path)"
class="sidebar-link"
>
<el-menu-item :index="resolvePath(onlyOneChild.path)" >
<el-icon v-if="onlyOneChild.meta?.icon">
<component :is="onlyOneChild.meta.icon" />
</el-icon>
<span v-if="onlyOneChild.meta?.title">{{ onlyOneChild.meta.title }}</span>
</el-menu-item>
</app-link>
</template>
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)">
<template #title>
<el-icon v-if="item.meta?.icon">
<component :is="item.meta.icon" />
</el-icon>
<span v-if="item.meta?.title">{{ item.meta.title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-sub-menu>
</div>
</template>
<style lang="scss" scoped>
.sidebar-link {
text-decoration: none;
}
.el-menu-item, .el-sub-menu {
background-color: transparent !important;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '@/store/settings'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
import SidebarItem from './SidebarItem.vue'
import Logo from './Logo.vue'
const route = useRoute()
const router = useRouter()
const settingsStore = useSettingsStore()
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const { sidebarLogo } = storeToRefs(settingsStore)
// 获取所有路由配置,而不仅仅是匹配的路由
const routes = computed(() => {
// 获取路由器中的所有路由
return router.options.routes.filter(item => {
// 过滤掉设置了hidden的路由
// @ts-ignore - hidden 属性在实际路由对象中存在,但TypeScript类型定义中没有
return !(item.hidden || (item.meta && item.meta.hidden))
})
})
const activeMenu = computed(() => {
const { meta, path } = route
// 如果设置了 path,侧边栏会高亮显示你设置的路径
if (meta.activeMenu) {
return meta.activeMenu as string
}
return path
})
const isCollapse = computed(() => !sidebar.value.opened)
</script>
<template>
<div class="sidebar-container-wrapper">
<logo v-if="sidebarLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
background-color="#304156"
text-color="#bfcbd9"
:unique-opened="false"
active-text-color="#409eff"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as vars;
.sidebar-container-wrapper {
height: 100%;
display: flex;
flex-direction: column;
// 主题定制
:deep(.el-menu) {
border: none;
height: 100%;
width: 100% !important;
}
:deep(.submenu-title-noDropdown:hover), :deep(.el-submenu__title:hover) {
background-color: vars.$menuHover !important;
}
:deep(.is-active > .el-submenu__title) {
color: vars.$subMenuActiveText !important;
}
.el-menu-item, .el-sub-menu {
background-color: transparent !important;
}
}
.scrollbar-wrapper {
flex: 1;
overflow-x: hidden !important;
}
</style>
export { default as Navbar } from './Navbar.vue'
export { default as Sidebar } from './Sidebar/index.vue'
export { default as AppMain } from './AppMain.vue'
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { Navbar, Sidebar, AppMain } from './components/index.ts'
// @ts-ignore: 导入 JS 混入文件,忽略类型检查
import ResizeMixin from './mixin/ResizeHandler.js'
import { useAppStore } from '@/store/app'
import { useSettingsStore } from '@/store/settings'
import { storeToRefs } from 'pinia'
export default defineComponent({
name: 'Layout',
components: {
Navbar,
Sidebar,
AppMain
},
mixins: [ResizeMixin],
setup() {
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const { sidebar, device } = storeToRefs(appStore)
const { fixedHeader } = storeToRefs(settingsStore)
const handleClickOutside = () => {
appStore.closeSideBar({ withoutAnimation: false })
}
const classObj = computed(() => {
return {
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === 'mobile'
}
})
return {
sidebar,
device,
fixedHeader,
classObj,
handleClickOutside
}
}
})
</script>
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<sidebar class="sidebar-container" />
<div class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar />
</div>
<app-main />
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/styles/variables' as vars;
@use '@/styles/mixin';
.app-wrapper {
@include mixin.clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar{
position: fixed;
top: 0;
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{vars.$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px)
}
.mobile .fixed-header {
width: 100%;
}
</style>
\ No newline at end of file
import { useAppStore } from '@/store/app'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route(route) {
const appStore = useAppStore()
if (appStore.device === 'mobile' && appStore.sidebar.opened) {
appStore.closeSideBar({ withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeUnmount() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
const appStore = useAppStore()
if (isMobile) {
appStore.toggleDevice('mobile')
appStore.closeSideBar({ withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
const appStore = useAppStore()
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop')
if (isMobile) {
appStore.closeSideBar({ withoutAnimation: true })
}
}
}
}
}
import { createApp } from 'vue'
import 'virtual:uno.css'
import App from './App.vue'
import router from './router'
import pinia from './store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import {
UserFilled,
Lock,
View,
Hide,
CaretBottom,
HomeFilled,
Menu,
Document,
Tickets,
List,
Folder,
Files,
Loading,
VideoPlay,
Refresh,
Back,
Upload,
Plus,
Monitor,
Key,
House,
Clock,
Message,
DataAnalysis,
Notebook,
Setting
} from '@element-plus/icons-vue'
import 'normalize.css/normalize.css'
import './styles/index.scss'
import './style.css'
import './permission' // 权限控制
const app = createApp(App)
// 注册 Element Plus 图标
app.component('Monitor', Monitor)
app.component('Document', Document)
app.component('List', List)
app.component('Key', Key)
app.component('House', House)
app.component('Clock', Clock)
app.component('Message', Message)
app.component('DataAnalysis', DataAnalysis)
app.component('Notebook', Notebook)
app.component('Setting', Setting)
app.component('UserFilled', UserFilled)
app.component('Lock', Lock)
app.component('View', View)
app.component('Hide', Hide)
app.component('CaretBottom', CaretBottom)
app.component('HomeFilled', HomeFilled)
app.component('Menu', Menu)
app.component('Tickets', Tickets)
app.component('Folder', Folder)
app.component('Files', Files)
app.component('Loading', Loading)
app.component('VideoPlay', VideoPlay)
app.component('Refresh', Refresh)
app.component('Back', Back)
app.component('Upload', Upload)
app.component('Plus', Plus)
app.use(router)
app.use(pinia)
app.use(ElementPlus, { size: 'default' })
app.mount('#app')
import router from './router'
import { useUserStore } from './store/user'
import { getToken } from '@/utils/auth'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// NProgress 配置
NProgress.configure({ showSpinner: false })
// 白名单路由
const whiteList = ['/login', '/404']
router.beforeEach(async (to, from, next) => {
// 开始进度条
NProgress.start()
const userStore = useUserStore()
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
// 已登录,重定向到首页
next({ path: '/' })
NProgress.done()
} else {
// 确定用户是否有用户信息
const hasGetUserInfo = userStore.name !== ''
if (hasGetUserInfo) {
next()
} else {
try {
// 获取用户信息
await userStore.getInfo()
next()
} catch (error) {
// 移除令牌并重新登录
await userStore.resetToken()
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
// 没有令牌
if (whiteList.indexOf(to.path) !== -1) {
// 在白名单中直接通过
next()
} else {
// 重定向到登录页
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
// 完成进度条
NProgress.done()
})
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
/**
* 路由配置
*
* hidden: true 设为true则不会显示在侧边栏
* alwaysShow: true 设为true则总是显示根菜单
* redirect: noRedirect 设为noRedirect则不会在面包屑中重定向
* name:'router-name' 用于 <keep-alive> (必须设置!!!)
* meta : {
* roles: ['admin','editor'] 控制页面角色(可设置多个角色)
* title: 'title' 显示在侧边栏和面包屑上的名称(推荐设置)
* icon: 'svg-name' 侧边栏图标
* breadcrumb: false 设为false则不显示在面包屑中
* activeMenu: '/example/list' 如果设置路径,侧边栏将高亮你设置的路径
* }
*/
/**
* constantRoutes
* 没有权限要求的基础页面
* 所有角色都可访问
*/
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' },
hidden: true
} as unknown as RouteRecordRaw,
{
path: '/404',
component: () => import('@/views/404.vue'),
hidden: true
} as unknown as RouteRecordRaw,
{
path: '',
component: Layout,
redirect: '/dashboard',
children: [{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/home/home.vue'),
meta: { title: '控制台', icon: 'Monitor' }
}]
} as unknown as RouteRecordRaw,
{
path: "/scrapyd-server",
component: Layout,
children: [
{
path: "list",
name: "scrapyd-server-list",
component: () => import("@/views/scrapyd-server/index.vue"),
meta: { title: "Scrapyd服务", icon: "Key" }
},
]
} as unknown as RouteRecordRaw,
{
path: '/project',
component: Layout,
redirect: '/project/list',
name: 'project',
meta: { title: '项目', icon: 'House' },
children: [
{
path: 'list',
name: 'project-list',
component: () => import('@/views/project/index.vue'),
meta: { title: '项目列表', icon: 'House' }
},
{
path: 'version-list',
name: 'project-version',
component: () => import('@/views/project/versions.vue'),
meta: { title: '版本列表', icon: 'List' },
hidden: true
}
]
} as unknown as RouteRecordRaw,
{
path: '/spider',
component: Layout,
children: [
{
path: 'list',
name: 'spider-list',
component: () => import('@/views/spider/index.vue'),
meta: { title: 'Spider', icon: 'List' }
}
]
} as unknown as RouteRecordRaw,
{
path: '/schedule',
component: Layout,
children: [
{
path: 'list',
name: 'schedule-list',
component: () => import('@/views/schedule/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
}
]
} as unknown as RouteRecordRaw,
{
path: "/schedule-log",
component: Layout,
children: [
{
path: 'list',
name: 'schedule-log-list',
component: () => import('@/views/schedule/logs.vue'),
meta: { title: '调度日志', icon: 'Message' },
}
]
} as unknown as RouteRecordRaw,
{
path: "/job",
component: Layout,
children: [
{
path: "list",
name: "job-list",
component: () => import("@/views/job/index.vue"),
meta: { title: "任务列表", icon: "List" },
}
]
} as unknown as RouteRecordRaw,
{
path: '/stats',
component: Layout,
children: [
{
path: 'list',
name: 'stats-list',
component: () => import('@/views/stats/index.vue'),
meta: { title: '数据统计', icon: 'DataAnalysis' }
}
]
} as unknown as RouteRecordRaw,
{
path: "/logs",
component: Layout,
redirect: "/logs/list",
name: "logs",
meta: { title: "日志", icon: "Notebook" },
hidden: true,
children: [
{
path: "list",
name: "logs-list",
component: () => import("@/views/log/LogIndex.vue"),
meta: { title: "运行日志", icon: "Notebook" }
},
{
path: "project",
name: "logs-project",
component: () => import("@/views/log/LogProject.vue"),
meta: { title: "项目日志", icon: "Notebook" },
hidden: true
},
{
path: "spider",
name: "logs-project-spider",
component: () => import("@/views/log/LogSpider.vue"),
meta: { title: "Spider日志", icon: "Notebook" },
hidden: true
},
{
path: "job",
name: "logs-project-spider-job",
component: () => import("@/views/log/LogJob.vue"),
meta: { title: "任务日志", icon: "Notebook" },
hidden: true
}
]
} as unknown as RouteRecordRaw,
{
path: "/system",
component: Layout,
redirect: "/system/login-list",
name: "system",
meta: { title: "系统管理", icon: "Setting" },
hidden: true,
children: [
{
path: "login-list",
name: "system-login-list",
component: () => import('@/views/system/SystemIndex.vue'),
meta: { title: "登录日志", icon: "Setting" }
},
]
} as unknown as RouteRecordRaw,
// 404 页面必须放在最后 !!!
// { path: '/:pathMatch(.*)*', redirect: '/404', hidden: true } as unknown as RouteRecordRaw
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
export function resetRouter() {
const newRouter = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
// @ts-ignore
router.matcher = newRouter.matcher
}
export default router
interface ISettings {
title: string
fixedHeader: boolean
sidebarLogo: boolean
}
const settings: ISettings = {
title: 'Spider Admin Pro',
fixedHeader: true,
sidebarLogo: true
}
export default settings
import { defineStore } from 'pinia'
interface AppState {
sidebar: {
opened: boolean
withoutAnimation: boolean
},
device: 'desktop' | 'mobile'
}
export const useAppStore = defineStore('app', {
state: (): AppState => ({
sidebar: {
opened: localStorage.getItem('sidebarStatus')
? !!+localStorage.getItem('sidebarStatus')! // 将获取到的值转换为布尔值 其中 + 为先转为数字 !! 为转成 bool 值
: true,
withoutAnimation: false
},
device: 'desktop',
}),
actions: {
toggleSidebar() {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = false
if (this.sidebar.opened) {
localStorage.setItem('sidebarStatus', '1')
} else {
localStorage.setItem('sidebarStatus', '0')
}
},
closeSideBar(options: { withoutAnimation: boolean }) {
this.sidebar.opened = false
this.sidebar.withoutAnimation = options.withoutAnimation
localStorage.setItem('sidebarStatus', '0')
},
toggleDevice(device: 'desktop' | 'mobile') {
this.device = device
}
}
})
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
import { defineStore } from 'pinia'
import defaultSettings from '@/settings'
const { fixedHeader, sidebarLogo } = defaultSettings
interface SettingsState {
fixedHeader: boolean
sidebarLogo: boolean
}
export const useSettingsStore = defineStore('settings', {
state: (): SettingsState => ({
fixedHeader,
sidebarLogo
}),
actions: {
changeSetting({ key, value }: { key: string, value: any }) {
// @ts-ignore
this[key] = value
}
}
})
import { defineStore } from 'pinia'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { resetRouter } from '@/router'
import { useAppStore } from './app'
import { login } from '@/api/user'
interface UserState {
token: string
name: string
avatar: string
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
token: getToken(),
name: '',
avatar: 'user-avatar.gif'
}),
actions: {
// 用户登录
async login(userInfo: {username: string, password: string}) {
const { username, password } = userInfo
try {
const res = await login({
username: username.trim(),
password: password
})
if (res.code === 0) {
const token = res.data.token
this.token = token
setToken(token)
return Promise.resolve()
} else {
return Promise.reject(res.message)
}
} catch (error) {
return Promise.reject(error)
}
},
// 获取用户信息
async getInfo() {
// 这里保留了 getInfo 方法,后续会根据实际需求修改
this.name = 'Admin'
this.avatar = 'user-avatar.gif'
return {
name: this.name,
avatar: this.avatar,
roles: ['admin']
}
},
// 用户登出
async logout() {
this.token = ''
this.name = ''
removeToken()
resetRouter()
},
// 重置令牌
resetToken() {
this.token = ''
removeToken()
},
// 侧边栏相关
toggleSidebar() {
const appStore = useAppStore()
appStore.toggleSidebar()
}
}
})
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
/* max-width: 1280px; */
margin: 0 auto;
/* padding: 2rem; */
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@use 'variables' as vars;
@use 'mixin';
// 基本布局样式
.app-wrapper {
@include mixin.clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{vars.$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.mobile .fixed-header {
width: 100%;
}
// 主容器
#app {
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: vars.$sideBarWidth;
position: relative;
}
// 移动端响应
.mobile {
.main-container {
margin-left: 0;
}
.sidebar-container {
transition: transform 0.28s;
width: vars.$sideBarWidth !important;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-#{vars.$sideBarWidth}, 0, 0);
}
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}
@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
@mixin relative {
position: relative;
width: 100%;
height: 100%;
}
@use 'variables' as vars;
// 侧边栏样式
#app {
.sidebar-container {
transition: width 0.28s;
width: vars.$sideBarWidth !important;
background-color: vars.$menuBg;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}
.el-scrollbar__view {
height: 100%;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
}
// menu hover
.submenu-title-noDropdown,
.el-sub-menu__title {
&:hover {
background-color: vars.$menuHover !important;
}
}
.is-active > .el-sub-menu__title {
color: vars.$subMenuActiveText !important;
}
& .nest-menu .el-sub-menu > .el-sub-menu__title,
& .el-sub-menu .el-menu-item {
min-width: vars.$sideBarWidth !important;
background-color: vars.$subMenuBg !important;
&:hover {
background-color: vars.$subMenuHover !important;
}
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.submenu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-menu-item {
&.is-active {
background-color: vars.$menuHover !important;
}
}
.el-sub-menu {
overflow: hidden;
& > .el-sub-menu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
.el-sub-menu__icon-arrow {
display: none;
}
}
}
.el-menu--collapse {
.el-sub-menu {
& > .el-sub-menu__title {
& > span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-sub-menu {
min-width: vars.$sideBarWidth !important;
}
}
// 垂直菜单样式
.el-menu--vertical {
& > .el-menu {
.svg-icon {
margin-right: 16px;
}
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.el-menu-item {
&:hover {
// you can use $subMenuHover
background-color: vars.$menuHover !important;
}
}
// 下拉菜单滚动条样式
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
// global transition css
/* fade */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.28s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* breadcrumb transition */
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-20px);
}
// Base variables
$menuText: #bfcbd9;
$menuActiveText: #409eff;
$subMenuActiveText: #f4f4f5;
$menuBg: #304156;
$menuHover: #263445;
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
$sideBarWidth: 150px;
// 菜单item字体大小和高度
$sideBarMenuItemFontSize: 14px;
$sideBarMenuItemHeight: 49px;
$sideBarMenuItemLineHeight: 49px;
// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
sideBarMenuItemFontSize: $sideBarMenuItemFontSize;
sideBarMenuItemHeight: $sideBarMenuItemHeight;
sideBarMenuItemLineHeight: $sideBarMenuItemLineHeight;
}
@use 'variables' as vars;
@use 'mixin';
@forward 'layout';
@forward 'sidebar';
@forward 'transition';
/* 全局样式 */
html, body {
margin: 0;
padding: 0;
font-size: 14px;
height: 100%;
width: 100%;
overflow-x: hidden; /* 防止浏览器默认滚动条 */
}
body {
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
/* 页面容器 */
.app-container {
padding: 20px;
box-sizing: border-box;
height: 100%;
// max-width: 1200px;
margin: 0 auto;
}
/* 间距 */
.ml-sm { margin-left: 5px; }
.ml-md { margin-left: 10px; }
.ml-lg { margin-left: 20px; }
.mt-sm { margin-top: 5px; }
.mt-md { margin-top: 10px; }
.mt-lg { margin-top: 20px; }
/* 辅助样式 */
.text-center { text-align: center; }
.text-right { text-align: right; }
/* 浮动 */
.pull-left { float: left; }
.pull-right { float: right; }
.clearfix:after {
content: "";
display: table;
clear: both;
}
declare module '@element-plus/icons-vue';
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
icon?: string
hidden?: boolean
alwaysShow?: boolean
breadcrumb?: boolean
noCache?: boolean
activeMenu?: string
noShowingChildren?: boolean
roles?: string[]
permissions?: string[]
}
}
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
declare module '*.css' {
const content: Record<string, string>;
export default content;
}
declare module '*.less' {
const content: Record<string, string>;
export default content;
}
declare module '*.sass' {
const content: Record<string, string>;
export default content;
}
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
export function getToken(): string {
return Cookies.get(TokenKey) || ''
}
export function setToken(token: string) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from '@/utils/auth'
// 创建axios实例
const instance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '',
timeout: 10000, // 毫秒
headers: {
'Content-Type': 'application/json; charset=utf-8'
}
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
// 保持与原项目相同的Token头设置
config.headers['Token'] = token
}
return config
},
(err) => {
console.log('请求出错')
return Promise.reject(err)
}
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
let result = res.data
if (typeof res.data === 'string') {
try {
result = JSON.parse(res.data)
} catch (e) {
// 不是JSON格式,保持原样
}
} else {
// 4000 token无效或者过期
if (result.code === 4000) {
removeToken()
window.location.reload()
}
if (result.code === 0) {
result.ok = true
} else {
result.ok = false
ElMessage.error(result.msg || '请求失败')
}
}
return result
},
(err) => {
ElMessage.error('网络请求出错, 请检查网络')
return Promise.reject(err)
}
)
// HTTP 请求方法常量
export const GET = 'get'
export const POST = 'post'
export const PUT = 'put'
export const DELETE = 'delete'
// 请求函数类型
export interface RequestConfig {
url: string;
method: 'get' | 'post' | 'put' | 'delete';
params?: any;
data?: any;
headers?: Record<string, string>;
}
export interface ApiResponse<T = any> {
code: number
data: T
message: string
}
export interface QueryParams {
page?: number
size?: number
project?: string
spider?: string
scrapydServerId: string
order_prop?: string
order_type?: string
[key: string]: any
}
// 请求函数
export const request = (config: RequestConfig) => {
return instance(config);
}
export function isExternal(path: string): boolean {
return /^(https?:|mailto:|tel:)/.test(path)
}
// 校验用户名
export const validateUsername = (rule: any, value: string, callback: Function) => {
if (value.trim().length < 3) {
callback(new Error('用户名不能少于3个字符'))
} else {
callback()
}
}
// 校验密码
export const validatePassword = (rule: any, value: string, callback: Function) => {
if (value.length < 6) {
callback(new Error('密码不能少于6个字符'))
} else {
callback()
}
}
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
</script>
<template>
<div class="wscn-http404-container">
<div class="wscn-http404">
<div class="pic-404">
<img class="pic-404__parent" src="@/assets/404_images/404.svg" alt="404">
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.svg" alt="404">
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.svg" alt="404">
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.svg" alt="404">
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__info">页面不存在</div>
<div class="bullshit__headline">您访问的页面找不到了...</div>
<div class="bullshit__info">请检查您输入的URL是否正确</div>
<a class="bullshit__return-home" @click="goHome">返回首页</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wscn-http404-container {
transform: translate(-50%, -50%);
position: absolute;
top: 40%;
left: 50%;
}
.wscn-http404 {
position: relative;
width: 1200px;
padding: 0 50px;
overflow: hidden;
.pic-404 {
position: relative;
float: left;
width: 600px;
overflow: hidden;
&__parent {
width: 100%;
}
&__child {
position: absolute;
&.left {
width: 80px;
top: 17px;
left: 220px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
&.mid {
width: 46px;
top: 10px;
left: 420px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1.2s;
}
&.right {
width: 62px;
top: 100px;
left: 500px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 30px 0;
overflow: hidden;
&__oops {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&__headline {
font-size: 20px;
line-height: 24px;
color: #222;
font-weight: bold;
opacity: 0;
margin-bottom: 10px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
&__info {
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
margin-bottom: 30px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.4s;
animation-fill-mode: forwards;
}
&__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
background: #1482f0;
border-radius: 100px;
text-align: center;
color: #ffffff;
opacity: 0;
font-size: 14px;
line-height: 36px;
cursor: pointer;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.6s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
}
}
</style>
<template>
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="title">{{ title }}</div>
<div class="desc">{{ desc }}</div>
</div>
<slot name="tools"></slot>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps({
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
}
})
</script>
<style lang="scss" scoped>
.card {
background: #fff;
border-radius: 5px;
margin-bottom: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
&-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
&-title {
.title {
font-size: 16px;
font-weight: 500;
}
.desc {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
&-body {
padding: 20px;
}
}
</style>
<template>
<home-card desc="Config" title="系统配置">
<el-form ref="formRef" :model="form" label-width="120px" v-loading="loading">
<el-form-item label="Scrapyd服务器数量">
<span style="margin-left: 20px">{{ scrapyd_count }}</span>
</el-form-item>
<el-form-item label="默认用户名">
<el-input v-model="form.username" disabled />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入新密码,不修改请留空"
/>
</el-form-item>
<el-form-item label="Token过期时间">
<el-input-number
v-model="form.token_expire_minutes"
:min="1"
:max="10080"
/>
<span class="form-tip">分钟</span>
</el-form-item>
<el-form-item label="调度历史保留">
<el-input-number
v-model="form.schedule_history_keep_days"
:min="1"
:max="365"
/>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="登录历史保留">
<el-input-number
v-model="form.login_history_keep_days"
:min="1"
:max="365"
/>
<span class="form-tip"></span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="submitting">保存</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</home-card>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import HomeCard from './HomeCard.vue'
import * as systemApi from '@/api/system'
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitting = ref(false)
const form = reactive({
username: 'admin',
password: '',
token_expire_minutes: 1440, // 默认1天
schedule_history_keep_days: 30,
login_history_keep_days: 30
})
// 获取系统配置
const getSystemConfig = async () => {
loading.value = true
try {
const res = await systemApi.getSystemConfig()
if (res.code === 0) {
// 避免直接覆盖整个表单,只更新需要的字段
form.username = res.data.username || 'admin'
form.token_expire_minutes = res.data.token_expire_minutes || 1440
form.schedule_history_keep_days = res.data.schedule_history_keep_days || 30
form.login_history_keep_days = res.data.login_history_keep_days || 30
// 不设置密码,避免无意修改
form.password = ''
}
} catch (error) {
console.error('获取系统配置失败:', error)
} finally {
loading.value = false
}
}
// 提交表单
const submitForm = async () => {
if (!formRef.value) return
submitting.value = true
try {
// 如果密码为空,不包含密码字段
const data = form.password
? { ...form }
: {
username: form.username,
token_expire_minutes: form.token_expire_minutes,
schedule_history_keep_days: form.schedule_history_keep_days,
login_history_keep_days: form.login_history_keep_days
}
const res = await systemApi.updateSystemConfig(data)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '保存成功!'
})
// 清空密码字段
form.password = ''
}
} catch (error) {
console.error('更新系统配置失败:', error)
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
getSystemConfig()
}
const scrapyd_count = ref(0)
const getScrapydServerListInfo = async () => {
try {
const res = await systemApi.getScrapydServerList()
if (res.code === 0) {
scrapyd_count.value = res.data.total
}
} catch (error) {
console.error('获取Scrapyd服务器列表失败:', error)
}
}
onMounted(() => {
getSystemConfig()
getScrapydServerListInfo()
})
</script>
<style lang="scss" scoped>
.form-tip {
margin-left: 10px;
color: #909399;
font-size: 14px;
}
</style>
<template>
<home-card desc="System" title="系统数据">
<active-plate :info-list="list" />
</home-card>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import ActivePlate from '@/components/ActivePlate.vue'
import HomeCard from './HomeCard.vue'
import * as systemApi from '@/api/system'
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
}
})
const list = ref([])
const getData = async () => {
try {
const res = await systemApi.getSystemData({
scrapydServerId: props.scrapydServerId
})
if (res.code === 0) {
// 将后端数据转换为组件需要的格式
list.value = res.data.map((item: any, index: number) => {
// 根据数据类型选择不同的图标
let icon = 'Monitor'
if (item.title.includes('项目')) {
icon = 'Files'
} else if (item.title.includes('爬虫')) {
icon = 'Lock'
} else if (item.title.includes('版本')) {
icon = 'View'
} else if (item.title.includes('任务')) {
icon = 'Folder'
} else if (item.title.includes('服务')) {
icon = 'Upload'
}
return {
title: item.title,
icon: icon,
count: item.count || 0,
color: getColorByIndex(index)
}
})
}
} catch (error) {
console.error('获取系统数据失败:', error)
}
}
// 根据索引获取颜色
const getColorByIndex = (index: number): string => {
const colors = ['#11A0F8', '#FFBB44', '#7ACE4C', '#36CBCB', '#F47F92', '#FBD437']
return colors[index % colors.length]
}
// 监听服务器ID变化,重新获取数据
watch(() => props.scrapydServerId, (newVal) => {
if (newVal) {
getData()
}
})
onMounted(() => {
// 只有当scrapydServerId存在时才获取数据
if (props.scrapydServerId) {
getData()
}
})
</script>
<style lang="scss" scoped>
</style>
<template>
<div>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="12">
<home-system-panel
title="磁盘信息"
desc="Disk"
:list="diskUsageList"
:value="String(diskUsage.percent)"
subtext="磁盘使用率"
/>
</el-col>
<el-col :xs="24" :sm="24" :md="12">
<home-system-panel
title="内存信息"
desc="Memory"
:list="virtualMemoryList"
:value="String(virtualMemory.percent)"
subtext="内存使用率"
/>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import HomeSystemPanel from './HomeSystemPanel.vue'
import * as systemApi from '@/api/system'
const virtualMemory = ref<{
total_format: string
used_format: string
available_format: string
percent: number
}>({
total_format: '0',
used_format: '0',
available_format: '0',
percent: 0
})
interface VirtualMemoryType {
value: string
name: string
color: string
}
const virtualMemoryList = ref<VirtualMemoryType[]>([])
interface DiskUsageType {
total_format: string
used_format: string
free_format: string
percent: number
}
const diskUsage = ref<DiskUsageType>({
total_format: '0',
used_format: '0',
free_format: '0',
percent: 0
})
interface DiskUsageListType {
value: string
name: string
color: string
}
const diskUsageList = ref<DiskUsageListType[]>([])
const getData = async () => {
try {
const res = await systemApi.getSystemInfo()
if (res.code === 0) {
virtualMemory.value = res.data.virtual_memory
virtualMemoryList.value = [
{
value: virtualMemory.value.total_format,
name: '总内存',
color: '#3AA1FF'
},
{
value: virtualMemory.value.used_format,
name: '已用内存',
color: '#36CBCB'
},
{
value: virtualMemory.value.available_format,
name: '可用内存',
color: '#4ECB73'
}
]
diskUsage.value = res.data.disk_usage
diskUsageList.value = [
{
value: diskUsage.value.total_format,
name: '总大小',
color: '#FBD437'
},
{
value: diskUsage.value.used_format,
name: '已用大小',
color: '#36CBCB'
},
{
value: diskUsage.value.free_format,
name: '剩余大小',
color: '#F47F92'
}
]
}
} catch (error) {
console.error('获取系统信息失败:', error)
}
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
</style>
<template>
<home-card :title="title" :desc="desc">
<div class="system-panel">
<div class="chart-container">
<el-progress
type="dashboard"
:percentage="parseInt(value)"
:color="getProgressColor"
:stroke-width="10"
>
<template #default>
<div class="progress-content">
<span class="progress-value">{{ parseInt(value) }}%</span>
<span class="progress-text">{{ subtext }}</span>
</div>
</template>
</el-progress>
</div>
<div class="info-list">
<div class="info-item" v-for="(item, index) in list" :key="index">
<div class="info-label">
<span class="color-dot" :style="{ background: item.color }"></span>
<span>{{ item.name }}</span>
</div>
<div class="info-value">{{ item.value }}</div>
</div>
</div>
</div>
</home-card>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import HomeCard from './HomeCard.vue'
const props = defineProps({
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
},
list: {
type: Array as () => {color: string, name: string, value: string}[],
default: () => []
},
value: {
type: String,
default: 0
},
subtext: {
type: String,
default: ''
}
})
const getProgressColor = computed(() => {
const value = parseInt(props.value as string)
if (value < 60) {
return '#67C23A'
} else if (value < 80) {
return '#E6A23C'
} else {
return '#F56C6C'
}
})
</script>
<style lang="scss" scoped>
.system-panel {
display: flex;
flex-wrap: wrap;
.chart-container {
flex: 0 0 200px;
display: flex;
justify-content: center;
align-items: center;
}
.progress-content {
display: flex;
flex-direction: column;
align-items: center;
.progress-value {
font-size: 24px;
font-weight: bold;
}
.progress-text {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
.info-list {
flex: 1;
margin-left: 20px;
.info-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
.info-label {
color: #606266;
display: flex;
align-items: center;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
}
.info-value {
font-weight: 500;
color: #303133;
}
}
}
}
@media (max-width: 768px) {
.system-panel {
flex-direction: column;
.chart-container {
flex: none;
margin-bottom: 20px;
}
.info-list {
margin-left: 0;
}
}
}
</style>
<script setup lang="ts">
import { ref } from 'vue'
import SelectScrapydServer from '@/components/SelectScrapydServer.vue'
import HomeDataInfo from './components/HomeDataInfo.vue'
import HomeSystemInfo from './components/HomeSystemInfo.vue'
import HomeConfig from './components/HomeConfig.vue'
const scrapydServerId = ref('')
const handleSelectScrapydServerInit = (options: any[]) => {
if (options.length > 0) {
scrapydServerId.value = options[0].value
}
}
const resetData = (id: string) => {
scrapydServerId.value = id
localStorage.setItem('scrapyd_server_id', id)
}
</script>
<template>
<div class="app-container home-container">
<select-scrapyd-server
v-model:value="scrapydServerId"
@on-init="handleSelectScrapydServerInit"
@change="resetData"
/>
<div class="mt-md">
<home-data-info
:key="scrapydServerId"
:scrapyd-server-id="scrapydServerId"
/>
<home-system-info />
<!-- <home-config /> -->
</div>
</div>
</template>
<style lang="scss" scoped>
.home-container {
padding: 20px;
height: 100%;
.mt-md {
margin-top: 20px;
}
}
</style>
<template>
<div class="job-cancel">
<el-button
:size="size"
@click="handleCancel"
:disabled="disabled"
>取消</el-button>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
const props = defineProps({
job: { type: String, required: true },
project: { type: String, required: true },
scrapydServerId: { type: String, required: true },
size: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['success']);
// 取消任务
const handleCancel = async () => {
try {
const res = await projectApi.cancelJob({
project: props.project,
job: props.job,
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
ElMessage.success('取消成功');
emit('success');
} else {
ElMessage.error(res.message || '取消失败');
}
} catch (error) {
console.error('取消任务失败:', error);
ElMessage.error('取消任务失败');
}
};
</script>
<style lang="scss" scoped>
.job-cancel {
display: inline-block;
}
</style>
<template>
<el-button
:size="size"
@click="handleCancel"
:disabled="disabled"
>取消所有任务</el-button>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
const props = defineProps({
project: { type: String, required: true },
scrapydServerId: { type: String, required: true },
size: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['success']);
// 取消所有任务
const handleCancel = async () => {
try {
const res = await projectApi.cancelAllJob({
project: props.project,
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
ElMessage.success('取消成功');
emit('success');
} else {
ElMessage.error(res.message || '取消失败');
}
} catch (error) {
console.error('取消所有任务失败:', error);
ElMessage.error('取消所有任务失败');
}
};
</script>
<style lang="scss" scoped>
</style>
<template>
<div class="job-table">
<el-table
v-loading="loading"
:data="jobList"
border
fit
highlight-current-row
>
<el-table-column align="center" label="序号" width="60">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="Spider" align="center">
<template #default="scope">
{{ scope.row.spider }}
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="80">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="持续时间" align="center" width="100">
<template #default="scope">
{{ scope.row.duration_str || '-' }}
</template>
</el-table-column>
<el-table-column label="开始时间" align="center" width="170">
<template #default="scope">
{{ scope.row.start_time || '-' }}
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" width="170">
<template #default="scope">
{{ scope.row.end_time || '-' }}
</template>
</el-table-column>
<el-table-column label="PID" align="center" width="80">
<template #default="scope">
{{ scope.row.pid || '-' }}
</template>
</el-table-column>
<el-table-column label="查看日志" align="center" width="100">
<template #default="scope">
<router-link :to="{
path: '/project/logs',
query: {
scrapydServerId: scrapydServerId,
project: project,
spider: scope.row.spider,
job: scope.row.id
}
}" target="_blank">
<el-button text type="primary" size="small">
<el-icon><Document /></el-icon>
查看
</el-button>
</router-link>
</template>
</el-table-column>
<el-table-column label="取消运行" align="center" width="100">
<template #default="scope">
<JobCancel
:job="scope.row.id"
:project="project"
:scrapyd-server-id="scrapydServerId"
:disabled="scope.row.status === 'finished'"
@success="$emit('success')"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from 'vue';
import { Document } from '@element-plus/icons-vue';
import JobCancel from '@/views/job/components/JobCancel.vue';
const props = defineProps({
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
}
});
const emit = defineEmits(['success']);
const jobList = ref(props.data);
// 监听props.data的变化
watch(() => props.data, (newVal) => {
jobList.value = newVal;
}, { deep: true });
// 根据状态返回不同的类型
const getStatusType = (status: string) => {
switch (status) {
case 'running':
return 'primary';
case 'finished':
return 'success';
case 'pending':
return 'warning';
default:
return 'info';
}
};
</script>
<style lang="scss" scoped>
.job-table {
margin-top: 20px;
}
</style>
<template>
<div class="job-tool">
<div class="job-tool-row">
<el-radio-group
v-model="status"
:size="size"
@change="handleStatusChange"
>
<el-radio-button label="">全部 {{ statusInfo.total }}</el-radio-button>
<el-radio-button label="pending">等待中 {{ statusInfo.pending }}</el-radio-button>
<el-radio-button label="running">运行中 {{ statusInfo.running }}</el-radio-button>
<el-radio-button label="finished">已结束 {{ statusInfo.finished }}</el-radio-button>
</el-radio-group>
<SelectScrapydServer
class="ml-sm"
v-model:value="scrapydServerId"
@on-init="handleServerInit"
@change="handleServerChange"
/>
<SelectProject
ref="projectSelect"
class="ml-sm"
:size="size"
:scrapyd-server-id="scrapydServerId"
v-model:value="project"
@on-init="handleProjectInit"
@change="handleProjectChange"
/>
<JobCancelAll
v-if="project"
class="ml-sm"
:size="size"
:scrapyd-server-id="scrapydServerId"
:project="project"
@success="$emit('success')"
/>
<AutoRefresh
class="ml-sm"
:size="size"
@refresh="$emit('success')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import SelectScrapydServer from '@/components/SelectScrapydServer.vue';
import SelectProject from '@/components/SelectProject.vue';
import AutoRefresh from '@/components/AutoRefresh.vue';
import JobCancelAll from '@/views/job/components/JobCancelAll.vue';
const props = defineProps({
statusInfo: {
type: Object,
default: () => ({
total: 0,
finished: 0,
pending: 0,
running: 0
})
},
size: {
type: String,
default: 'default'
}
});
const emit = defineEmits([
'status-change',
'project-change',
'server-change',
'success',
'on-init'
]);
const route = useRoute();
const projectSelect = ref<any>(null);
const status = ref('');
const project = ref('');
const scrapydServerId = ref('');
// 处理状态变化
const handleStatusChange = () => {
emit('status-change', status.value);
};
// 处理项目变化
const handleProjectChange = (val: string) => {
if (val) {
localStorage.setItem('project', val);
emit('project-change', val);
}
};
// 处理项目选择初始化
const handleProjectInit = (data: any[]) => {
emit('on-init', data);
};
// 处理服务器变化
const handleServerChange = () => {
// 切换服务器时重新获取项目列表
if (projectSelect.value) {
projectSelect.value.resetData();
}
emit('server-change', scrapydServerId.value);
};
// 处理服务器选择初始化
const handleServerInit = (servers: any[]) => {
// 如果当前没有选择服务器,而且有可选的服务器,则选择第一个
if (!scrapydServerId.value && servers && servers.length > 0) {
scrapydServerId.value = servers[0].value;
}
};
onMounted(() => {
// 从路由参数中获取初始值
if (route.query.scrapydServerId) {
scrapydServerId.value = route.query.scrapydServerId as string;
}
if (route.query.project) {
project.value = route.query.project as string;
}
if (route.query.status) {
status.value = route.query.status as string;
}
});
</script>
<style lang="scss" scoped>
.job-tool {
margin-bottom: 20px;
}
.job-tool-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.ml-sm {
margin-left: 10px;
}
</style>
<template>
<div class="app-container">
<JobTool
:status-info="statusInfo"
@status-change="handleStatusChange"
@project-change="handleProjectChange"
@server-change="handleServerChange"
@success="fetchJobList"
@on-init="handleToolInit"
/>
<JobTable
:data="jobList"
:loading="loading"
:scrapyd-server-id="scrapydServerId"
:project="project"
@success="fetchJobList"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
import JobTool from '@/views/job/components/JobTool.vue';
import JobTable from '@/views/job/components/JobTable.vue';
const route = useRoute();
const loading = ref(false);
const jobList = ref<any[]>([]);
const scrapydServerId = ref('');
const project = ref('');
const status = ref('');
// 状态统计信息
const statusInfo = reactive({
total: 0,
finished: 0,
pending: 0,
running: 0
});
// 获取任务列表
const fetchJobList = async () => {
if (!scrapydServerId.value || !project.value) {
return;
}
loading.value = true;
try {
const res = await projectApi.getJobList({
project: project.value,
status: status.value,
scrapydServerId: scrapydServerId.value
});
if (res.code === 0) {
jobList.value = res.data.list || [];
// 更新状态统计
statusInfo.total = res.data.total || 0;
statusInfo.pending = res.data.pending || 0;
statusInfo.running = res.data.running || 0;
statusInfo.finished = res.data.finished || 0;
} else {
ElMessage.error(res.message || '获取任务列表失败');
}
} catch (error) {
console.error('获取任务列表失败:', error);
ElMessage.error('获取任务列表失败');
} finally {
loading.value = false;
}
};
// 处理状态变化
const handleStatusChange = (val: string) => {
status.value = val;
fetchJobList();
};
// 处理项目变化
const handleProjectChange = (val: string) => {
project.value = val;
fetchJobList();
};
// 处理服务器变化
const handleServerChange = (val: string) => {
scrapydServerId.value = val;
};
// 处理工具栏初始化
const handleToolInit = (data: any[]) => {
// 如果有项目数据且当前未选择项目,则选择第一个
if (data && data.length > 0 && !project.value) {
project.value = data[0].project;
fetchJobList();
}
};
onMounted(() => {
// 从路由参数中获取初始值
if (route.query.scrapydServerId) {
scrapydServerId.value = route.query.scrapydServerId as string;
}
if (route.query.project) {
project.value = route.query.project as string;
}
if (route.query.status) {
status.value = route.query.status as string;
}
// 如果有必要参数,则自动获取任务列表
if (scrapydServerId.value && project.value) {
fetchJobList();
}
});
</script>
<style lang="scss" scoped>
.app-container {
padding: 20px;
}
</style>
<template>
<div class="app-container">
<SelectScrapydServer
v-model:value="scrapydServerId"
@on-init="handleSelectScrapydServerInit"
@change="handleScrapydServerChange"
></SelectScrapydServer>
<LogTable
class="mt-md"
type="logs"
:scrapydServerId="scrapydServerId"
v-loading="listLoading"
:data="list"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import SelectScrapydServer from '@/components/SelectScrapydServer.vue';
import { getLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getLogs({
scrapydServerId: scrapydServerId.value,
});
if (res) {
list.value = res.data;
}
} catch (error) {
console.error('获取日志列表失败:', error);
} finally {
listLoading.value = false;
}
}
const handleScrapydServerChange = () => {
getData();
};
const handleSelectScrapydServerInit = (data: any) => {
if (!scrapydServerId.value) {
if (data && data.length > 0) {
scrapydServerId.value = data[0].value;
}
}
getData();
};
onMounted(() => {
const queryScrapydServerId = route.query.scrapydServerId;
if (queryScrapydServerId) {
scrapydServerId.value = queryScrapydServerId as string;
}
});
</script>
<style lang="scss" scoped></style>
<template>
<div class="app-container">
<AutoRefresh
@refresh="getData"
/>
<el-divider />
<div
class="log-content"
v-loading="listLoading"
>
<pre v-if="content">{{ content }}</pre>
<div v-else>暂无数据</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import AutoRefresh from '@/components/AutoRefresh.vue';
import { getJobLog } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const spider = ref('');
const job = ref('');
const listLoading = ref(true);
const content = ref('');
const getData = async () => {
listLoading.value = true;
try {
const res = await getJobLog({
scrapydServerId: scrapydServerId.value,
project: project.value,
spider: spider.value,
job: job.value,
});
content.value = res as unknown as string || '';
} catch (error) {
console.error('获取任务日志失败:', error);
} finally {
listLoading.value = false;
}
}
onMounted(() => {
project.value = route.query.project as string;
spider.value = route.query.spider as string;
job.value = route.query.job as string;
scrapydServerId.value = route.query.scrapydServerId as string;
getData();
});
</script>
<style lang="scss" scoped>
.log-content {
overflow: auto;
text-align: left;
pre {
margin: 0;
}
}
</style>
<template>
<div class="app-container">
<div style="height: 20px"></div>
<LogTable
type="project"
v-loading="listLoading"
:scrapydServerId="scrapydServerId"
:project="project"
:data="list"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import { getProjectLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getProjectLogs({
project: project.value,
scrapydServerId: scrapydServerId.value,
});
list.value = res.data;
} catch (error) {
console.error('获取项目日志失败:', error);
} finally {
listLoading.value = false;
}
}
const handleStatusChange = () => {
getData();
};
onMounted(() => {
project.value = route.query.project as string;
scrapydServerId.value = route.query.scrapydServerId as string;
getData();
});
</script>
<style lang="scss" scoped></style>
<template>
<div class="app-container">
<div style="height:20px"></div>
<LogTable
type="spider"
v-loading="listLoading"
:data="list"
:scrapydServerId="scrapydServerId"
:project="project"
:spider="spider"
stripe
border
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import { getSpiderLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const spider = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getSpiderLogs({
scrapydServerId: scrapydServerId.value,
project: project.value,
spider: spider.value,
});
list.value = res.data;
} catch (error) {
console.error('获取爬虫日志失败:', error);
} finally {
listLoading.value = false;
}
}
const handleStatusChange = () => {
getData();
};
onMounted(() => {
scrapydServerId.value = route.query.scrapydServerId as string;
project.value = route.query.project as string;
spider.value = route.query.spider as string;
getData();
});
</script>
<style lang="scss" scoped>
</style>
<template>
<el-table v-bind="$attrs">
<el-table-column
align="center"
label="序号"
width="60"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
label="文件名"
align="center"
>
<template #default="scope">
<router-link
:to="getRoute(scope.row.filename)"
:target="type === 'spider' ? '_blank' : '_self'"
>
{{ scope.row.filename }}
</router-link>
</template>
</el-table-column>
<el-table-column
label="文件大小"
align="center"
>
<template #default="scope">
{{ scope.row.size }}
</template>
</el-table-column>
<el-table-column
label="文件类型"
align="center"
>
<template #default="scope">
{{ scope.row.content_type }}
</template>
</el-table-column>
<el-table-column
label="文件编码"
align="center"
>
<template #default="scope">
{{ scope.row.content_encoding }}
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps({
type: { type: String, required: false },
scrapydServerId: { type: String, required: false, default: '' },
project: { type: String, required: false, default: '' },
spider: { type: String, required: false, default: '' }
});
const getRoute = (filename: string) => {
if (props.type === 'logs') {
return {
name: 'logs-project',
query: {
scrapydServerId: props.scrapydServerId,
project: filename,
},
};
} else if (props.type === 'project') {
return {
name: 'logs-project-spider',
query: {
scrapydServerId: props.scrapydServerId,
project: props.project,
spider: filename,
},
};
} else if (props.type === 'spider') {
return {
name: 'logs-project-spider-job',
query: {
scrapydServerId: props.scrapydServerId,
project: props.project,
spider: props.spider,
job: filename,
},
};
}
return {};
};
</script>
<style lang="scss" scoped>
</style>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { UserFilled } from '@element-plus/icons-vue'
// 表单验证
const validateUsername = (rule: any, value: string, callback: Function) => {
if (value.trim().length < 3) {
callback(new Error('用户名不能少于3个字符'))
} else {
callback()
}
}
const validatePassword = (rule: any, value: string, callback: Function) => {
if (value.length < 6) {
callback(new Error('密码不能少于6个字符'))
} else {
callback()
}
}
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 数据
const loginForm = reactive({
username: '',
password: ''
})
const loginRules = {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
}
const loading = ref(false)
const passwordType = ref('password')
const redirect = ref<string | undefined>(undefined)
// 监听路由变化
if (route.query?.redirect) {
redirect.value = route.query.redirect as string
}
// 密码显示切换
const showPwd = () => {
passwordType.value = passwordType.value === 'password' ? '' : 'password'
}
// 登录表单引用
const loginFormRef = ref<any>(null)
// 登录方法
const handleLogin = () => {
loginFormRef.value.validate(async (valid: boolean) => {
if (valid) {
loading.value = true
try {
await userStore.login(loginForm)
router.push({ path: redirect.value || '/' })
} catch (error: any) {
console.error('登录失败:', error)
} finally {
loading.value = false
}
} else {
console.log('登录表单验证失败')
}
})
}
</script>
<template>
<div class="login-container">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
auto-complete="on"
label-position="left"
>
<div class="title-container">
<h3 class="title">FK Spider Admin</h3>
<div class="sub-title">爬虫管理系统</div>
</div>
<el-form-item prop="username">
<span class="svg-container">
<el-icon><UserFilled /></el-icon>
</span>
<el-input
ref="username"
v-model="loginForm.username"
placeholder="用户名"
name="username"
type="text"
tabindex="1"
auto-complete="on"
/>
</el-form-item>
<el-form-item prop="password">
<span class="svg-container">
<el-icon><Lock /></el-icon>
</span>
<el-input
:key="passwordType"
ref="password"
v-model="loginForm.password"
:type="passwordType"
placeholder="密码"
name="password"
tabindex="2"
auto-complete="on"
@keyup.enter="handleLogin"
/>
<span class="show-pwd" @click="showPwd">
<el-icon>
<component :is="passwordType === 'password' ? 'View' : 'Hide'" />
</el-icon>
</span>
</el-form-item>
<el-button
:loading="loading"
type="primary"
style="width: 100%; margin-bottom: 30px"
@click.prevent="handleLogin"
>登录</el-button>
<div class="tips">
<!-- 提示信息可以根据需要添加 -->
</div>
</el-form>
</div>
</template>
<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #889aa4;
$light_gray: #eee;
.login-container {
min-height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
.login-form {
position: relative;
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}
.tips {
font-size: 14px;
color: #fff;
margin-bottom: 10px;
span {
&:first-of-type {
margin-right: 16px;
}
}
}
.svg-container {
padding: 6px 5px 6px 15px;
color: $dark_gray;
vertical-align: middle;
width: 30px;
display: inline-block;
}
.title-container {
position: relative;
.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 10px auto;
text-align: center;
font-weight: bold;
}
.sub-title {
font-size: 14px;
color: #ddd;
margin: 0px auto 40px auto;
text-align: center;
}
}
.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
}
</style>
<style lang="scss">
/* 修复input 背景不协调 和光标变色 */
$bg: #283443;
$light_gray: #fff;
$cursor: #fff;
.login-container {
.el-input {
height: 47px;
width: 85%;
.el-input__wrapper {
background-color: transparent;
border: 0px;
}
input {
background: transparent;
border: 0px;
-webkit-appearance: none;
appearance: none;
border-radius: 0px;
padding: 12px 5px 12px 15px;
color: $light_gray;
height: 47px;
caret-color: $cursor;
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px $bg inset !important;
-webkit-text-fill-color: $cursor !important;
}
}
}
.el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
color: #454545;
}
.el-button {
height: 34px;
}
}
</style>
<template>
<div class="app-container">
<!-- 添加服务器选择器 -->
<SelectScrapydServer
v-model:value="scrapydServerId"
@change="handleServerChange"
@on-init="handleServerInit"
/>
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>爬虫项目管理</span>
<div class="right-panel">
<el-button type="primary" @click="handleAddProject">
<el-icon><Plus /></el-icon>添加项目
</el-button>
<el-button type="primary" @click="refreshProjectList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="projectList" stripe border style="width: 100%">
<el-table-column label="序号" width="80" align="center">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="name" label="项目名称" align="center" width="800"/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button text type="primary" size="small" @click="viewSpiders(scope.row)">
<el-icon><Document /></el-icon>Spider
</el-button>
</template>
</el-table-column>
<el-table-column label="查看版本" align="center">
<template #default="scope">
<el-button text type="success" size="small" @click="viewVersions(scope.row)">
<el-icon><Files /></el-icon>版本
</el-button>
</template>
</el-table-column>
<el-table-column label="查看任务" align="center">
<template #default="scope">
<el-button text type="info" size="small" @click="viewJobs(scope.row)">
<el-icon><List /></el-icon>任务
</el-button>
</template>
</el-table-column>
<el-table-column label="查看日志" align="center">
<template #default="scope">
<el-button text type="warning" size="small" @click="viewLogs(scope.row)">
<el-icon><Notebook /></el-icon>日志
</el-button>
</template>
</el-table-column>
<el-table-column label="更新版本" align="center">
<template #default="scope">
<el-button text type="primary" size="small" @click="handleUpdateVersion(scope.row)">
<el-icon><EditPen /></el-icon>编辑
</el-button>
</template>
</el-table-column>
<el-table-column label="删除项目" align="center">
<template #default="scope">
<el-button text type="danger" size="small" @click="handleDeleteProject(scope.row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加项目对话框 -->
<el-dialog v-model="addDialogVisible" :title="dialogTitle" width="500px">
<el-form :model="addForm" label-width="100px" :rules="rules" ref="addFormRef">
<el-form-item label="项目文件" prop="file">
<el-upload
class="upload-demo"
drag
action=""
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">请上传爬虫项目文件(.zip 或 .egg)</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="项目名称" prop="project">
<el-input v-model="addForm.project" placeholder="请输入项目名称" :disabled="isEditMode" />
</el-form-item>
<el-form-item label="版本" prop="version">
<el-input v-model="addForm.version" placeholder="请输入版本号" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitAddProject" :loading="submitLoading">确认</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadFile } from 'element-plus'
import { getProjectList, addProjectVersion, deleteProject } from '@/api/project'
import { Plus, Refresh, Upload, Document, Files, List, Notebook, EditPen, Delete } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import SelectScrapydServer from '@/components/SelectScrapydServer.vue'
const router = useRouter()
const loading = ref(false)
const projectList = ref([])
const addDialogVisible = ref(false)
const submitLoading = ref(false)
const addForm = ref({
project: '',
version: '',
file: null as File | null
})
const addFormRef = ref<any>()
const scrapydServerId = ref<string>('')
const dialogTitle = ref('添加项目')
const isEditMode = ref(false)
// 表单验证规则
const rules = {
project: [
{ required: true, message: '请输入项目名称', trigger: 'blur' }
],
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' }
],
file: [
{ required: true, message: '请上传项目文件', trigger: 'change' }
]
}
// 获取项目列表
const fetchProjectList = async () => {
if (!scrapydServerId.value) {
ElMessage.warning('请选择Scrapyd服务器')
return
}
loading.value = true
try {
const response = await getProjectList({ scrapydServerId: scrapydServerId.value })
projectList.value = response.data.map((item: any) => ({ name: item.project }))
} catch (error) {
console.error('获取项目列表失败:', error)
ElMessage.error('获取项目列表失败')
} finally {
loading.value = false
}
}
// 刷新项目列表
const refreshProjectList = () => {
fetchProjectList()
}
// 查看爬虫
const viewSpiders = (row: any) => {
router.push({
path: '/spider',
query: {
project: row.name,
scrapydServerId: scrapydServerId.value
}
})
}
// 查看版本
const viewVersions = (row: any) => {
router.push({
path: '/project/version-list',
query: {
project: row.name,
scrapydServerId: scrapydServerId.value
}
})
}
// 查看任务
const viewJobs = (row: any) => {
router.push({
path: '/job/list',
query: {
project: row.name,
scrapydServerId: scrapydServerId.value
}
})
}
// 查看日志
const viewLogs = (row: any) => {
router.push({
path: '/logs/project',
query: {
project: row.name,
scrapydServerId: scrapydServerId.value
}
})
}
// 更新版本
const handleUpdateVersion = (row: any) => {
isEditMode.value = true
dialogTitle.value = '更新版本'
addDialogVisible.value = true
addForm.value = {
project: row.name,
version: '',
file: null
}
}
// 处理添加项目
const handleAddProject = () => {
isEditMode.value = false
dialogTitle.value = '添加项目'
addDialogVisible.value = true
addForm.value = {
project: '',
version: '',
file: null
}
}
// 处理文件变更
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
// 检查文件类型
const fileName = file.name || '';
if (!fileName.endsWith('.egg') && !fileName.endsWith('.zip')) {
ElMessage.warning('只能上传 .egg 或 .zip 格式的文件!');
return false;
}
// 设置文件和自动填写项目名称
addForm.value.file = file.raw
// 自动提取项目名称(仅在添加模式下)
if (!isEditMode.value && !addForm.value.project) {
addForm.value.project = fileName.replace(/\.(egg|zip)$/, '');
// 自动生成版本号
if (!addForm.value.version) {
const now = new Date();
addForm.value.version = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
}
}
return true;
}
return false;
}
// 提交添加项目
const submitAddProject = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid: any) => {
if (valid && addForm.value.file) {
submitLoading.value = true
const formData = new FormData()
formData.append('project', addForm.value.project)
formData.append('version', addForm.value.version)
formData.append('file', addForm.value.file)
formData.append('scrapydServerId', scrapydServerId.value)
try {
await addProjectVersion(formData)
ElMessage.success(isEditMode.value ? '更新版本成功' : '添加项目成功')
addDialogVisible.value = false
fetchProjectList()
} catch (error) {
console.error('操作失败:', error)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
})
}
// 删除项目
const handleDeleteProject = (row: any) => {
ElMessageBox.confirm(
`确定要删除项目 ${row.name} 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await deleteProject({
project: row.name,
scrapydServerId: scrapydServerId.value
})
ElMessage.success('删除项目成功')
fetchProjectList()
} catch (error) {
console.error('删除项目失败:', error)
ElMessage.error('删除项目失败')
}
}).catch(() => {
// 取消删除操作
})
}
// 处理服务器选择初始化
const handleServerInit = (servers: any[]) => {
if (servers && servers.length > 0) {
const serverId = servers[0].value
scrapydServerId.value = serverId
// 存储当前选择的服务器ID到本地缓存
localStorage.setItem('scrapyd_server_id', serverId)
fetchProjectList()
}
}
// 处理服务器选择变更
const handleServerChange = (serverId: string) => {
scrapydServerId.value = serverId
// 存储当前选择的服务器ID到本地缓存
localStorage.setItem('scrapyd_server_id', serverId)
fetchProjectList()
}
onMounted(() => {
// 尝试从本地缓存获取上次选择的服务器ID
const savedServerId = localStorage.getItem('scrapyd_server_id')
if (savedServerId) {
scrapydServerId.value = savedServerId
}
})
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.right-panel {
display: flex;
gap: 10px;
}
.el-icon {
margin-right: 5px;
}
.box-card {
margin-top: 20px;
}
</style>
<template>
<div class="app-container">
<div class="page-header">
<el-button @click="goBack">
<el-icon><Back /></el-icon>返回
</el-button>
<h2>项目版本管理: {{ project }}</h2>
</div>
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>版本列表</span>
<div class="right-panel">
<el-button type="primary" @click="handleAddVersion">
<el-icon><Plus /></el-icon>添加版本
</el-button>
<el-button type="primary" @click="refreshVersionList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="versionList" border stripe style="width: 100%">
<el-table-column label="序号" width="80" align="center">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="version" label="版本号" />
<el-table-column prop="format_version" label="上传时间" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button type="danger" size="small" @click="handleDeleteVersion(scope.row)">
删除版本
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加版本对话框 -->
<el-dialog v-model="addDialogVisible" title="添加版本" width="500px">
<el-form :model="addForm" label-width="100px" :rules="rules" ref="addFormRef">
<el-form-item label="项目文件" prop="file">
<el-upload
class="upload-demo"
drag
action=""
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">请上传爬虫项目文件(.zip 或 .egg)</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="版本" prop="version">
<el-input v-model="addForm.version" placeholder="请输入版本号" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitAddVersion" :loading="submitLoading">确认</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { getProjectVersions, addProjectVersion, deleteProjectVersion } from '@/api/project'
import { Plus, Refresh, Upload, Back } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const project = ref('')
const loading = ref(false)
const versionList = ref<{
version: string
format_version: string
}[]>([])
const addDialogVisible = ref(false)
const submitLoading = ref(false)
const addForm = ref({
version: '',
file: null as File | null
})
const addFormRef = ref()
// 表单验证规则
const rules = {
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' }
],
file: [
{ required: true, message: '请上传项目文件', trigger: 'change' }
]
}
// 返回项目列表
const goBack = () => {
router.push('/project')
}
// 获取版本列表
const fetchVersionList = async () => {
if (!project.value) return
loading.value = true
try {
const response = await getProjectVersions({ project: project.value, scrapydServerId: localStorage.getItem('scrapyd_server_id')! })
versionList.value = response.data
} catch (error) {
console.error('获取版本列表失败:', error)
ElMessage.error('获取版本列表失败')
} finally {
loading.value = false
}
}
// 刷新版本列表
const refreshVersionList = () => {
fetchVersionList()
}
// 处理添加版本
const handleAddVersion = () => {
addDialogVisible.value = true
addForm.value = {
version: '',
file: null
}
}
// 处理文件变更
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
addForm.value.file = file.raw
}
}
// 提交添加版本
const submitAddVersion = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid: any) => {
if (valid && addForm.value.file) {
submitLoading.value = true
const formData = new FormData()
formData.append('project', project.value)
formData.append('version', addForm.value.version)
formData.append('file', addForm.value.file)
try {
await addProjectVersion(formData)
ElMessage.success('添加版本成功')
addDialogVisible.value = false
fetchVersionList()
} catch (error) {
console.error('添加版本失败:', error)
ElMessage.error('添加版本失败')
} finally {
submitLoading.value = false
}
}
})
}
// 删除版本
const handleDeleteVersion = (row: any) => {
ElMessageBox.confirm(
`确定要删除版本 ${row.version} 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await deleteProjectVersion({
project: project.value,
version: row.version
})
ElMessage.success('删除版本成功')
fetchVersionList()
} catch (error) {
console.error('删除版本失败:', error)
ElMessage.error('删除版本失败')
}
}).catch(() => {
// 取消删除操作
})
}
onMounted(() => {
project.value = route.query.project as string
if (!project.value) {
ElMessage.error('缺少项目参数')
router.push('/project')
return
}
fetchVersionList()
})
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 20px;
gap: 10px;
h2 {
margin: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.right-panel {
display: flex;
gap: 10px;
}
.el-icon {
margin-right: 5px;
}
</style>
<template>
<span>
<!-- 修改 -->
<el-button
v-if="job_id"
size="small"
@click="handleDialogVisibleClick"
type="text"
>
<el-icon><Edit /></el-icon>
</el-button>
<!-- 添加 -->
<el-button
v-else-if="type=='table-add'"
size="small"
type="text"
@click="handleDialogVisibleClick"
>
<el-icon><Plus /></el-icon>
添加
</el-button>
<!-- 添加 -->
<el-button
v-else
size="small"
type="primary"
@click="handleDialogVisibleClick"
>
<el-icon><Plus /></el-icon>
添加
</el-button>
<el-dialog
:title="btnText + '任务'"
v-model="dialogVisible"
width="500px"
center
>
<schedule-form
v-if="dialogVisible"
:scrapyd-server-id="scrapydServerId"
:job_id="job_id"
:project="project"
:spider="spider"
@on-success="handleFormSuccess"
@on-cancel="handleDialogClose"
/>
</el-dialog>
</span>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { Edit, Plus } from '@element-plus/icons-vue'
const props = defineProps({
job_id: {
type: String,
default: ''
},
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
},
spider: {
type: String,
default: ''
},
type: {
type: String,
default: ''
}
})
const emit = defineEmits(['on-success'])
const dialogVisible = ref(false)
const btnText = computed(() => {
return props.job_id ? '修改' : '添加'
})
const handleDialogVisibleClick = () => {
dialogVisible.value = true
}
const handleDialogClose = () => {
dialogVisible.value = false
}
const handleFormSuccess = () => {
dialogVisible.value = false
emit('on-success')
}
</script>
<style lang="scss" scoped>
</style>
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
v-loading="loading"
>
<el-form-item label="项目名称" prop="project">
<el-select
v-model="form.project"
placeholder="请选择项目"
clearable
filterable
style="width: 100%"
@change="handleProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="爬虫名称" prop="spider">
<el-select
v-model="form.spider"
placeholder="请选择爬虫"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in spiderOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="Cron表达式" prop="cron">
<el-input
v-model="form.cron"
placeholder="例如: */5 * * * *"
>
<template #append>
<el-popover
placement="bottom"
title="Cron表达式帮助"
:width="300"
trigger="click"
>
<template #reference>
<el-button>帮助</el-button>
</template>
<div>
<p>Cron表达式格式:分 时 日 月 周</p>
<p>示例:</p>
<ul>
<li>每5分钟:*/5 * * * *</li>
<li>每小时:0 * * * *</li>
<li>每天凌晨1点:0 1 * * *</li>
<li>每周一3点:0 3 * * 1</li>
<li>每月1号零点:0 0 1 * *</li>
</ul>
</div>
</el-popover>
</template>
</el-input>
</el-form-item>
<el-form-item label="调度服务器" prop="schedule_type">
<el-radio-group v-model="form.schedule_type">
<el-radio
v-for="item in scheduleTypeOptions"
:key="item.value"
:label="item.value"
>{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="选择服务器" prop="scrapyd_server_id" v-if="form.schedule_type === ScheduleTypeEnum.ONLY_ONE_SERVER">
<el-select
v-model="form.scrapyd_server_id"
placeholder="请选择服务器"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in serverOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="爬虫参数" prop="options">
<el-input
v-model="form.options"
type="textarea"
:rows="4"
placeholder='{"param1": "value1", "param2": "value2"}'
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="submitForm"
:loading="submitting"
>
确认
</el-button>
<el-button @click="cancelForm">取消</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import * as scheduleApi from '@/api/schedule'
import * as projectApi from '@/api/project'
import { ScheduleTypeOptions, ScheduleTypeEnum } from '@/enums/schedule-type-enum'
import type { QueryParams } from '@/utils/request'
const props = defineProps({
job_id: {
type: String,
default: ''
},
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
},
spider: {
type: String,
default: ''
}
})
const emit = defineEmits(['on-success', 'on-cancel'])
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitting = ref(false)
interface OptionType {
label: string
value: string
}
const projectOptions = ref<OptionType[]>([])
const spiderOptions = ref<OptionType[]>([])
const serverOptions = ref<OptionType[]>([])
const scheduleTypeOptions = ref(ScheduleTypeOptions)
const form = reactive({
project: props.project || '',
spider: props.spider || '',
cron: '*/5 * * * *', // 默认每5分钟执行一次
schedule_type: ScheduleTypeEnum.ONLY_ONE_SERVER,
scrapyd_server_id: props.scrapydServerId || '',
options: '{}'
})
const rules = reactive({
project: [
{ required: true, message: '请选择项目', trigger: 'change' }
],
spider: [
{ required: true, message: '请选择爬虫', trigger: 'change' }
],
cron: [
{ required: true, message: '请输入Cron表达式', trigger: 'blur' }
],
schedule_type: [
{ required: true, message: '请选择调度方式', trigger: 'change' }
],
scrapyd_server_id: [
{
required: true,
message: '请选择服务器',
trigger: 'change',
validator: (_rule: any, value: any, callback: any) => {
if (form.schedule_type === ScheduleTypeEnum.ONLY_ONE_SERVER && !value) {
callback(new Error('请选择服务器'))
} else {
callback()
}
}
}
]
})
// 初始化数据
const initData = async () => {
loading.value = true
try {
// 获取项目列表
await getProjects()
// 获取服务器列表
await getScrapydServers()
// 如果有job_id,则获取任务详情
if (props.job_id) {
await getJobDetail()
}
// 如果有项目名,则获取爬虫列表
if (form.project) {
await getSpiders()
}
} catch (error) {
console.error('初始化数据失败:', error)
} finally {
loading.value = false
}
}
// 获取项目列表
const getProjects = async () => {
try {
const scrapyd_server_id = localStorage.getItem('scrapyd_server_id')
const res = await projectApi.getProjectList({ scrapydServerId: scrapyd_server_id! })
if (res.code === 0) {
projectOptions.value = res.data.map((item: any) => ({
label: item.name,
value: item.name
}))
}
} catch (error) {
console.error('获取项目列表失败:', error)
}
}
// 获取爬虫列表
const getSpiders = async () => {
if (!form.project) return
try {
const res = await projectApi.getSpiders({ project: form.project, scrapydServerId: localStorage.getItem('scrapyd_server_id')! })
if (res.code === 0 && Array.isArray(res.data)) {
spiderOptions.value = res.data.map((item: string) => ({
label: item,
value: item
}))
}
} catch (error) {
console.error('获取爬虫列表失败:', error)
}
}
// 获取服务器列表
const getScrapydServers = async () => {
try {
const res = await scheduleApi.getScrapydServerPage()
if (res.code === 0) {
serverOptions.value = res.data.list.map((item: any) => ({
label: item.server_name,
value: item.id
}))
}
} catch (error) {
console.error('获取服务器列表失败:', error)
}
}
// 获取任务详情
const getJobDetail = async () => {
try {
const res = await scheduleApi.getJobDetail({ job_id: props.job_id, scrapydServerId: localStorage.getItem('scrapyd_server_id')! })
if (res.code === 0) {
const jobData = res.data
form.project = jobData.kwargs.project || ''
form.spider = jobData.kwargs.spider || ''
form.cron = jobData.kwargs.cron || ''
form.schedule_type = jobData.kwargs.schedule_type || ScheduleTypeEnum.ONLY_ONE_SERVER
form.scrapyd_server_id = jobData.kwargs.scrapyd_server_id || ''
form.options = jobData.kwargs.options ? JSON.stringify(jobData.kwargs.options) : '{}'
}
} catch (error) {
console.error('获取任务详情失败:', error)
}
}
// 项目变更时获取对应的爬虫
const handleProjectChange = async () => {
form.spider = ''
await getSpiders()
}
// 提交表单
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
let options = {}
try {
options = JSON.parse(form.options)
} catch (e) {
ElMessage.error('爬虫参数格式错误,请检查JSON格式')
submitting.value = false
return
}
const params: QueryParams = {
project: form.project,
spider: form.spider,
cron: form.cron,
schedule_type: form.schedule_type,
scrapydServerId: form.scrapyd_server_id,
options
}
if (form.schedule_type === ScheduleTypeEnum.ONLY_ONE_SERVER) {
params.scrapyd_server_id = form.scrapyd_server_id
}
if (props.job_id) {
params.job_id = props.job_id
}
const res = await scheduleApi.addJob(params)
if (res.code === 0) {
ElMessage({
type: 'success',
message: props.job_id ? '修改成功!' : '添加成功!'
})
emit('on-success')
}
} catch (error) {
console.error(props.job_id ? '修改任务失败:' : '添加任务失败:', error)
} finally {
submitting.value = false
}
}
})
}
// 取消表单
const cancelForm = () => {
emit('on-cancel')
}
// 监听项目变化
watch(() => form.project, async (newVal) => {
if (newVal) {
await getSpiders()
}
})
onMounted(() => {
initData()
})
</script>
<style lang="scss" scoped>
</style>
<template>
<el-switch
v-model="status"
:loading="loading"
:active-value="true"
:inactive-value="false"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { ref, defineProps, defineEmits } from 'vue'
import { ElMessage } from 'element-plus'
import * as scheduleApi from '@/api/schedule'
const props = defineProps({
job_id: {
type: String,
required: true
},
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const status = ref(props.modelValue)
const loading = ref(false)
const handleChange = async (val: boolean) => {
loading.value = true
try {
const res = val
? await scheduleApi.resumeJob({ job_id: props.job_id })
: await scheduleApi.pauseJob({ job_id: props.job_id })
if (res.code === 0) {
ElMessage({
type: 'success',
message: val ? '恢复任务成功!' : '暂停任务成功!'
})
emit('update:modelValue', val)
} else {
// 操作失败,回滚开关状态
status.value = !val
}
} catch (error) {
console.error(val ? '恢复任务失败:' : '暂停任务失败:', error)
// 出错,回滚开关状态
status.value = !val
} finally {
loading.value = false
}
}
</script>
<template>
<el-button
size="small"
type="danger"
@click="confirmRemove"
>
<el-icon><Delete /></el-icon>
清空任务
</el-button>
</template>
<script lang="ts" setup>
import { Delete } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import * as scheduleApi from '@/api/schedule'
const emit = defineEmits(['success'])
const confirmRemove = () => {
ElMessageBox.confirm('是否确认删除所有调度任务?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await scheduleApi.removeAllJobs()
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功!'
})
emit('success')
}
} catch (error) {
console.error('删除所有调度任务失败:', error)
}
})
.catch(() => {
// 用户取消操作
})
}
</script>
<style lang="scss" scoped>
</style>
<template>
<el-button
type="danger"
size="small"
@click="confirmRemove"
>
<el-icon><Delete /></el-icon>
</el-button>
</template>
<script lang="ts" setup>
import { Delete } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import * as scheduleApi from '@/api/schedule'
const props = defineProps({
job_id: {
type: String,
required: true
}
})
const emit = defineEmits(['success'])
const confirmRemove = () => {
ElMessageBox.confirm('是否确认删除该调度任务?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await scheduleApi.removeJob({
job_id: props.job_id
})
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功!'
})
emit('success')
}
} catch (error) {
console.error('删除调度任务失败:', error)
}
})
.catch(() => {
// 用户取消操作
})
}
</script>
<style lang="scss" scoped>
</style>
<template>
<el-popover
placement="bottom"
:width="270"
trigger="click"
>
<template #reference>
<el-button size="small">
<el-icon><Setting /></el-icon>
调度器状态
</el-button>
</template>
<div class="schedule-state">
<div class="state-title">调度器状态: {{ state.running ? '运行中' : '已停止' }}</div>
<div class="state-actions">
<el-button
v-if="!state.running"
type="success"
size="small"
@click="startScheduler"
>
启动调度器
</el-button>
<el-button
v-if="state.running"
type="danger"
size="small"
@click="confirmShutdown"
>
关闭调度器
</el-button>
<el-button
v-if="state.running && !state.paused"
type="warning"
size="small"
@click="pauseScheduler"
>
暂停调度器
</el-button>
<el-button
v-if="state.running && state.paused"
type="primary"
size="small"
@click="resumeScheduler"
>
恢复调度器
</el-button>
</div>
</div>
</el-popover>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { Setting } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import * as scheduleApi from '@/api/schedule'
const emit = defineEmits(['success'])
const state = reactive({
running: false,
paused: false
})
const getSchedulerState = async () => {
try {
const res = await scheduleApi.getScheduleState()
if (res.code === 0) {
state.running = res.data.running
state.paused = res.data.paused
}
} catch (error) {
console.error('获取调度器状态失败:', error)
}
}
const startScheduler = async () => {
try {
const res = await scheduleApi.startSchedule()
if (res.code === 0) {
ElMessage({
type: 'success',
message: '启动成功!'
})
await getSchedulerState()
emit('success')
}
} catch (error) {
console.error('启动调度器失败:', error)
}
}
const confirmShutdown = () => {
ElMessageBox.confirm('关闭调度器后将停止执行所有定时任务, 是否确认?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await shutdownScheduler()
})
.catch(() => {
// 用户取消操作
})
}
const shutdownScheduler = async () => {
try {
const res = await scheduleApi.shutdownSchedule()
if (res.code === 0) {
ElMessage({
type: 'success',
message: '关闭成功!'
})
await getSchedulerState()
emit('success')
}
} catch (error) {
console.error('关闭调度器失败:', error)
}
}
const pauseScheduler = async () => {
try {
const res = await scheduleApi.pauseSchedule()
if (res.code === 0) {
ElMessage({
type: 'success',
message: '暂停成功!'
})
await getSchedulerState()
emit('success')
}
} catch (error) {
console.error('暂停调度器失败:', error)
}
}
const resumeScheduler = async () => {
try {
const res = await scheduleApi.resumeSchedule()
if (res.code === 0) {
ElMessage({
type: 'success',
message: '恢复成功!'
})
await getSchedulerState()
emit('success')
}
} catch (error) {
console.error('恢复调度器失败:', error)
}
}
onMounted(() => {
getSchedulerState()
})
</script>
<style lang="scss" scoped>
.schedule-state {
.state-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 15px;
}
.state-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
}
</style>
<template>
<div>
<el-table
v-loading="loading"
:data="data"
border
fit
highlight-current-row
@sort-change="handleSortChange"
>
<el-table-column
align="center"
label="序号"
width="60"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
label="项目名"
header-align="center"
align="left"
width="150"
>
<template #default="scope">
{{ scope.row.kwargs.project }}
</template>
</el-table-column>
<el-table-column
label="Spider"
header-align="center"
align="left"
prop="spider"
min-width="100"
sortable
>
<template #default="scope">
{{ scope.row.kwargs.spider }}
</template>
</el-table-column>
<el-table-column
label="Cron"
align="center"
width="120"
>
<template #default="scope">
{{ scope.row.kwargs.cron }}
</template>
</el-table-column>
<el-table-column
label="调度方式"
align="center"
width="120"
>
<template #default="scope">
<span
:class="{
'font-medium':
scope.row.kwargs.schedule_type === ScheduleTypeEnum.RANDOM_SERVER,
}"
>{{ scope.row.schedule_type_label || '-' }}</span
>
</template>
</el-table-column>
<el-table-column
label="下次执行时间"
align="center"
width="170"
>
<template #default="scope">
{{ scope.row.next_run_time || '-' }}
</template>
</el-table-column>
<el-table-column
label="日志"
align="center"
width="120"
>
<template #default="scope">
<!-- 调度日志 -->
<router-link
:to="{
name: 'ScheduleLogs',
query: {
project: scope.row.kwargs.project,
spider: scope.row.kwargs.spider,
scrapydServerId: scope.row.kwargs.scrapyd_server_id,
job: scope.row.id,
},
}"
target="_blank"
>调度</router-link
>
<!-- 运行日志 -->
<router-link
:to="{
name: 'SpiderLogs',
query: {
project: scope.row.kwargs.project,
spider: scope.row.kwargs.spider,
scrapydServerId: scope.row.kwargs.scrapyd_server_id,
},
}"
target="_blank"
style="margin-left: 5px"
>运行</router-link
>
<!-- 运行统计 -->
<router-link
:to="{
name: 'Stats',
query: {
project: scope.row.kwargs.project,
spider: scope.row.kwargs.spider,
scrapydServerId: scope.row.kwargs.scrapyd_server_id,
},
}"
target="_blank"
style="margin-left: 5px"
>统计</router-link
>
</template>
</el-table-column>
<el-table-column
label="状态"
align="center"
width="65"
>
<template #default="scope">
<schedule-job-status
:job_id="scope.row.id"
v-model="scope.row.status"
/>
</template>
</el-table-column>
<el-table-column
label="操作"
align="center"
width="100"
>
<template #default="scope">
<spider-execute
:project="scope.row.kwargs.project"
:spider="scope.row.kwargs.spider"
:scrapyd-server-id="scope.row.kwargs.scrapyd_server_id"
:options="scope.row.kwargs.options"
@on-success="handleSuccess"
/>
<schedule-add
class="ml-sm"
:job_id="scope.row.id"
@on-success="handleSuccess"
/>
<schedule-remove-job
class="ml-sm"
:job_id="scope.row.id"
@success="handleSuccess"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script lang="ts" setup>
import { defineEmits, defineProps } from 'vue'
import { ScheduleTypeEnum } from '@/enums/schedule-type-enum'
defineProps({
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['success', 'sort-change'])
const handleSuccess = () => {
emit('success')
}
const handleSortChange = (column: any) => {
emit('sort-change', column)
}
</script>
<style lang="scss" scoped>
.ml-sm {
margin-left: 5px;
}
.font-medium {
font-weight: 500;
}
</style>
<template>
<div class="tool-container">
<schedule-add @on-success="handleSuccess" />
<schedule-remove-all-job
style="margin-left:20px"
@success="handleSuccess"
/>
<schedule-state
style="margin-left:20px"
@success="handleSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { defineEmits } from 'vue'
const emit = defineEmits(['success'])
const handleSuccess = () => {
emit('success')
}
</script>
<style lang="scss" scoped>
.tool-container {
display: flex;
}
</style>
<template>
<div class="app-container">
<schedule-tool @success="getData" />
<div style="height: 20px"></div>
<schedule-table
:data="list"
@success="getData"
@sort-change="handleSortChange"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ScheduleTypeOptions, ScheduleTypeEnum } from '@/enums/schedule-type-enum'
import * as scheduleApi from '@/api/schedule'
const list = ref([])
// 排序
const orderProp = ref('')
const orderType = ref('')
const getData = async () => {
const options = await getScrapydServerPage()
try {
const res = await scheduleApi.getJobs({scrapydServerId: localStorage.getItem('scrapydServerId')!})
if (res.code === 0) {
list.value = res.data.map((item: any) => {
// 服务名
let scrapyd_server = options.find(
(x: any) => x.value == item.scrapyd_server_id
)
item.scrapyd_server_label = scrapyd_server ? scrapyd_server.label : ''
// 调度方式
if (item.kwargs.schedule_type == ScheduleTypeEnum.ONLY_ONE_SERVER) {
item.schedule_type_label = item.scrapyd_server_label
} else {
let ScheduleTypeItem = ScheduleTypeOptions.find(
(x) => item.kwargs.schedule_type == x.value
)
item.schedule_type_label = ScheduleTypeItem
? ScheduleTypeItem.label
: ''
}
return item
})
}
} catch (error) {
console.error('获取调度任务列表失败:', error)
}
}
const handleSortChange = ({ column, prop, order }: any) => {
orderType.value = order
// 如果有排序方式再给排序字段
if (orderType.value) {
orderProp.value = prop
} else {
orderProp.value = ''
}
}
const getScrapydServerPage = async () => {
try {
const res = await scheduleApi.getScrapydServerPage()
return res.data?.list.map((item: any) => {
return {
label: item.server_name,
value: item.id + '',
}
}) || []
} catch (error) {
console.error('获取Scrapyd服务列表失败:', error)
return []
}
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
</style>
<template>
<div class="app-container">
<div class="filter-container">
<el-form :inline="true" :model="listQuery" class="demo-form-inline">
<el-form-item label="状态">
<el-radio-group v-model="status" size="small" @change="handleStatusChange">
<el-radio-button label="total">全部 {{ statusInfo.total || 0 }}</el-radio-button>
<el-radio-button label="success">成功 {{ statusInfo.success || 0 }}</el-radio-button>
<el-radio-button label="error">失败 {{ statusInfo.error || 0 }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="项目">
<el-input v-model="listQuery.project" placeholder="项目名称" clearable />
</el-form-item>
<el-form-item label="爬虫">
<el-input v-model="listQuery.spider" placeholder="爬虫名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getList">查询</el-button>
<el-button type="danger" @click="handleClearLogs">清空日志</el-button>
<el-button type="default" @click="setupAutoRefresh">
<el-icon v-if="isAutoRefreshing"><Loading /></el-icon>
{{ isAutoRefreshing ? '停止自动刷新' : '自动刷新' }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-table
v-loading="listLoading"
:data="list"
element-loading-text="Loading"
border
fit
highlight-current-row
>
<el-table-column align="center" label="ID" width="80">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="项目名" align="center">
<template #default="scope">
{{ scope.row.project }}
</template>
</el-table-column>
<el-table-column label="Spider" align="center">
<template #default="scope">
{{ scope.row.spider }}
</template>
</el-table-column>
<el-table-column label="调度模式" align="center">
<template #default="scope">
{{ scope.row.schedule_mode }}
</template>
</el-table-column>
<el-table-column label="调度状态" align="center">
<template #default="scope">
<el-tag :type="scope.row.status ? 'success' : 'danger'">
{{ scope.row.status ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="错误消息" align="center">
<template #default="scope">
{{ scope.row.message || '-' }}
</template>
</el-table-column>
<el-table-column label="运行状态" align="center">
<template #default="scope">
{{ scope.row.run_status || '-' }}
</template>
</el-table-column>
<el-table-column label="执行日志" align="center">
</el-table-column>
<el-table-column label="调度时间" align="center">
<template #default="scope">
{{ scope.row.create_time || '-' }}
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-config-provider :locale="zhCn">
<el-pagination
:current-page="listQuery.page"
:page-sizes="[10, 20, 30, 50]"
:page-size="listQuery.limit"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-config-provider>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute } from 'vue-router'
import { Loading } from '@element-plus/icons-vue'
import * as scheduleApi from '@/api/schedule'
// ElConfigProvider 组件
import { ElConfigProvider } from 'element-plus';
// 引入中文包
import zhCn from 'element-plus/es/locale/lang/zh-cn';
const route = useRoute()
const list = ref<{
id: number,
create_time: string,
message: string,
option: string,
project: string,
run_status: string,
schedule_job_id: string,
schedule_mode: string,
scrapyd_server_id: number,
spider: string,
spider_job_id: string,
status: boolean
}[]>([])
const total = ref(0)
const listLoading = ref(false)
const status = ref('total')
const isAutoRefreshing = ref(false)
let refreshInterval: any = null
const refreshFrequency = 5000 // 5秒刷新一次
const statusInfo = reactive({
total: 0,
success: 0,
error: 0
})
const listQuery = reactive({
page: 1,
limit: 10,
project: '',
spider: '',
job_id: '',
scrapydServerId: localStorage.getItem('scrapyd_server_id') || ''
})
// 获取日志列表
const getList = async () => {
listLoading.value = true
try {
const res = await scheduleApi.getScheduleLogs({
status: status.value,
...listQuery,
})
list.value = res.data.list || []
total.value = res.data.total || 0
// 更新状态计数
if (res.data) {
statusInfo.total = res.data.total || 0
statusInfo.success = res.data.success || 0
statusInfo.error = res.data.error || 0
}
} catch (error) {
console.error('获取调度日志失败:', error)
} finally {
listLoading.value = false
}
}
// 处理分页大小变化
const handleSizeChange = (val: number) => {
listQuery.limit = val
getList()
}
// 处理当前页变化
const handleCurrentChange = (val: number) => {
listQuery.page = val
getList()
}
// 处理状态变化
const handleStatusChange = () => {
listQuery.page = 1
getList()
}
// 设置或取消自动刷新
const setupAutoRefresh = () => {
if (isAutoRefreshing.value) {
clearInterval(refreshInterval)
refreshInterval = null
isAutoRefreshing.value = false
ElMessage({
type: 'info',
message: '已停止自动刷新'
})
} else {
refreshInterval = setInterval(() => {
getList()
}, refreshFrequency)
isAutoRefreshing.value = true
ElMessage({
type: 'success',
message: `已设置每${refreshFrequency/1000}秒自动刷新`
})
}
}
// 清空日志
const handleClearLogs = () => {
// 构建确认提示信息
let confirmMsg = '确认清空'
if (status.value !== 'total') {
confirmMsg += `${status.value === 'success' ? '成功' : '失败'}`
}
confirmMsg += '调度日志'
if (listQuery.project) {
confirmMsg += `(项目:${listQuery.project}`
if (listQuery.spider) {
confirmMsg += `,爬虫:${listQuery.spider}`
}
confirmMsg += ')'
} else if (listQuery.spider) {
confirmMsg += `(爬虫:${listQuery.spider})`
}
confirmMsg += '?'
ElMessageBox.confirm(confirmMsg, '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
// 构造清空参数,可以按项目、爬虫过滤
const clearParams = {
status: status.value === 'total' ? '' : status.value,
project: listQuery.project,
spider: listQuery.spider,
scrapydServerId: listQuery.scrapydServerId,
job_id: listQuery.job_id
}
await scheduleApi.removeScheduleLogs(clearParams)
ElMessage({
type: 'success',
message: '清空成功!'
})
getList()
} catch (error) {
console.error('清空日志失败:', error)
ElMessage({
type: 'error',
message: '清空失败,请稍后重试'
})
}
}).catch(() => {
// 取消操作
})
}
// 组件挂载时从路由参数获取初始过滤条件
onMounted(() => {
// 从路由参数中获取项目和爬虫名称
const queryProject = route.query.project as string
const querySpider = route.query.spider as string
const queryServerId = route.query.scrapydServerId as string
const queryJob = route.query.job as string
if (queryProject) listQuery.project = queryProject
if (querySpider) listQuery.spider = querySpider
if (queryServerId) listQuery.scrapydServerId = queryServerId
if (queryJob) listQuery.job_id = queryJob
getList()
})
// 组件卸载前清除定时器
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style lang="scss" scoped>
.filter-container {
padding-bottom: 10px;
text-align: left;
}
.pagination-container {
padding: 10px 0;
}
</style>
<template>
<div class="text-left">
<el-button
v-if="type === 'edit'"
size="small"
@click="dialogVisible = true"
type="primary"
:icon="Edit"
>编辑</el-button>
<el-button
v-else
@click="dialogVisible = true"
type="primary"
:icon="Plus"
>添加</el-button>
<teleport to="body">
<el-dialog
v-model="dialogVisible"
:title="(type === 'edit' ? '编辑' : '添加') + 'Scrapyd服务'"
width="500px"
center
@open="handleOpen"
>
<el-form
:model="form"
ref="formRef"
:rules="rules"
status-icon
label-width="80px"
size="default"
>
<el-form-item
label="服务名称"
prop="server_name"
>
<el-input v-model="form.server_name"></el-input>
</el-form-item>
<el-form-item
label="服务地址"
prop="server_url"
>
<el-input v-model="form.server_url"></el-input>
</el-form-item>
<el-form-item
label="用户名"
prop="username"
>
<el-input v-model="form.username"></el-input>
</el-form-item>
<el-form-item
label="密码"
prop="password"
>
<el-input v-model="form.password"></el-input>
</el-form-item>
<el-form-item
label="启用/禁用"
prop="status"
>
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
></el-switch>
</el-form-item>
</el-form>
<template #footer>
<span>
<el-button
size="default"
@click="dialogVisible = false"
>取 消</el-button>
<el-button
type="primary"
size="default"
@click="submit"
>确 定</el-button>
</span>
</template>
</el-dialog>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, defineProps, defineEmits, watch } from 'vue';
import { Edit, Plus } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import type { FormInstance } from 'element-plus';
import { addScrapydServer, updateScrapydServer } from '@/api/scrapydServer';
// 定义props
const props = defineProps({
type: {
type: String,
default: 'add'
},
row: {
type: Object,
default: () => null
}
});
// 定义事件
const emit = defineEmits(['success']);
// 表单实例引用
const formRef = ref<FormInstance>();
// 对话框可见性
const dialogVisible = ref(false);
// 表单数据
const form = reactive({
server_name: '',
server_url: '',
username: '',
password: '',
status: 1
});
// 表单验证规则
const rules = {
server_name: [
{ required: true, message: '请输入服务名称', trigger: 'blur' }
],
server_url: [
{ required: true, message: '请输入服务地址', trigger: 'blur' }
]
};
// 在对话框打开时处理数据
const handleOpen = () => {
getData();
};
// 获取数据
const getData = () => {
if (props.row && props.row.id) {
for (const key in form) {
if (Object.prototype.hasOwnProperty.call(form, key) && props.row[key] !== undefined) {
(form as any)[key] = props.row[key];
}
}
}
};
// 提交表单
const submit = () => {
formRef.value?.validate(async (valid) => {
if (valid) {
const params = {
server_name: form.server_name,
server_url: form.server_url,
username: form.username,
password: form.password,
status: form.status
};
try {
if (props.row && props.row.id) {
const res = await updateScrapydServer({
...params,
scrapydServerId: props.row.id
});
if (res.code === 0) {
ElMessage.success('更新成功');
emit('success');
dialogVisible.value = false;
} else {
ElMessage.error(res.message || '更新失败');
}
} else {
const res = await addScrapydServer(params);
if (res.code === 0) {
ElMessage.success('添加成功');
emit('success');
dialogVisible.value = false;
} else {
ElMessage.error(res.message || '添加失败');
}
}
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('操作失败');
}
}
});
};
</script>
<template>
<div>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="handleDelete"
>删除</el-button>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import { deleteScrapydServer } from '@/api/scrapydServer';
// 定义props
const props = defineProps({
row: {
type: Object,
default: () => ({})
}
});
// 定义事件
const emit = defineEmits(['success']);
// 处理删除
const handleDelete = () => {
ElMessageBox.confirm(`确定要删除服务"${props.row.server_name}"吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteScrapydServer({
scrapydServerId: props.row.id
});
if (res.code === 0) {
ElMessage.success('删除成功');
emit('success');
} else {
ElMessage.error(res.message || '删除失败');
}
} catch (error) {
console.error('删除失败:', error);
ElMessage.error('删除失败');
}
}).catch(() => {
// 用户取消,不做任何操作
});
};
</script>
<style lang="scss" scoped></style>
<template>
<div>
<el-table
:data="props.data"
v-loading="props.loading"
border
style="width: 100%"
>
<el-table-column
align="center"
label="序号"
width="60"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
label="服务名称"
align="center"
prop="server_name"
/>
<el-table-column
label="服务地址"
align="center"
prop="server_url"
/>
<el-table-column
label="用户名"
align="center"
width="100px"
>
<template #default="scope">
{{ scope.row.username || '-' }}
</template>
</el-table-column>
<el-table-column
label="密码"
align="center"
width="100px"
>
<template #default="scope">
<span v-if="scope.row.password">***</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
label="查看项目"
align="center"
width="100px"
>
<template #default="scope">
<router-link
:to="{
path: '/project/list',
query: { scrapydServerId: scope.row.id },
}"
>
<el-icon><Document /></el-icon> 项目
</router-link>
</template>
</el-table-column>
<el-table-column
label="启用/禁用"
align="center"
width="100px"
>
<template #default="scope">
<el-switch
v-model="scope.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row, $event)"
/>
</template>
</el-table-column>
<el-table-column
label="编辑"
align="center"
width="80px"
>
<template #default="scope">
<ScrapydServerAdd
type="edit"
:row="scope.row"
@success="emit('success')"
/>
</template>
</el-table-column>
<el-table-column
label="删除"
align="center"
width="80px"
>
<template #default="scope">
<ScrapydServerDelete
:row="scope.row"
@success="emit('success')"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue';
import { ElMessage } from 'element-plus';
import { Document } from '@element-plus/icons-vue';
import ScrapydServerAdd from './ScrapydServerAdd.vue';
import ScrapydServerDelete from './ScrapydServerDelete.vue';
import { updateScrapydServerStatus } from '@/api/scrapydServer';
// 定义props
const props = defineProps<{
data?: ScrapydServer[];
loading?: boolean;
}>();
// 定义事件
const emit = defineEmits(['success']);
// 定义类型
interface ScrapydServer {
id: number;
server_name: string;
server_url: string;
username?: string;
password?: string;
status: number;
}
// 处理状态变更
const handleStatusChange = async (row: ScrapydServer, value: number) => {
try {
const params = {
scrapydServerId: row.id,
status: value
};
const res = await updateScrapydServerStatus(params);
if (res.code === 0) {
ElMessage.success('更新成功');
} else {
ElMessage.error(res.message || '更新失败');
}
} catch (error) {
console.error('更新状态失败:', error);
ElMessage.error('更新失败');
}
};
</script>
<template>
<div>
<ScrapydServerAdd @success="$emit('success')"/>
</div>
</template>
<script setup lang="ts">
import { defineEmits } from 'vue';
import ScrapydServerAdd from './ScrapydServerAdd.vue';
// 定义事件
const emit = defineEmits(['success']);
</script>
<template>
<div class="app-container">
<ScrapydServerTool @success="getData" />
<div style="height:20px"></div>
<ScrapydServerTable
:data="list"
:loading="listLoading"
@success="getData"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import ScrapydServerTable from './components/ScrapydServerTable.vue'
import ScrapydServerTool from './components/ScrapydServerTool.vue'
import { getScrapydServerList } from '@/api/system'
const list = ref([])
const listLoading = ref(false)
const getData = async () => {
listLoading.value = true
try {
const res = await getScrapydServerList()
if (res.code === 0) {
list.value = res.data.list
}
} catch (error) {
console.error('获取数据失败:', error)
} finally {
listLoading.value = false
}
}
onMounted(() => {
getData()
})
</script>
<template>
<span>
<el-button
size="small"
type="text"
@click="handleDialogVisibleClick"
>
<el-icon><VideoPlay /></el-icon>
</el-button>
<el-dialog
title="运行一次"
v-model="dialogVisible"
width="500px"
center
>
<spider-form
v-if="dialogVisible"
:spider="spider"
:project="project"
:scrapyd-server-id="scrapydServerId"
:options="options"
@on-success="handleFormSuccess"
@on-cancel="handleDialogClose"
/>
</el-dialog>
</span>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
const props = defineProps({
scrapydServerId: { type: String, default: '' },
project: { type: String, default: '' },
spider: { type: String, default: '' },
options: { type: String, default: '' },
})
const emit = defineEmits(['on-success'])
const dialogVisible = ref(false)
const handleDialogVisibleClick = () => {
dialogVisible.value = true
}
const handleDialogClose = () => {
dialogVisible.value = false
}
const handleFormSuccess = () => {
dialogVisible.value = false
emit('on-success')
}
</script>
<style lang="scss" scoped></style>
<template>
<el-form
:model="form"
ref="formRef"
label-width="100px"
size="small"
:rules="rules"
>
<el-form-item
label="服务名称"
prop="scrapyd_server_id"
>
<el-select
v-model="form.scrapyd_server_id"
placeholder="请选择服务器"
style="width: 100%"
:disabled="scrapydServerId !== ''"
@change="handleServerChange"
>
<el-option
v-for="server in serverOptions"
:key="server.value"
:label="server.label"
:value="server.value"
/>
</el-select>
</el-form-item>
<el-form-item
label="项目名称"
prop="project"
>
<el-select
v-model="form.project"
placeholder="请选择项目"
style="width: 100%"
:disabled="project !== ''"
@change="handleProjectChange"
>
<el-option
v-for="proj in projectOptions"
:key="proj.value"
:label="proj.label"
:value="proj.value"
/>
</el-select>
</el-form-item>
<el-form-item
label="Spider名称"
prop="spider"
>
<el-select
v-model="form.spider"
placeholder="请选择爬虫"
style="width: 100%"
:disabled="spider !== ''"
filterable
>
<el-option
v-for="item in spiderOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
label="其他参数"
prop="options"
>
<el-input
type="textarea"
rows="3"
v-model="form.options"
placeholder='任何参数都将作为spider参数传递,例如:{"setting": "DOWNLOAD_DELAY=2"}'
/>
</el-form-item>
<el-form-item>
<el-button
size="small"
@click="handleCancel"
>取 消</el-button>
<el-button
type="primary"
size="small"
@click="submit"
:loading="submitting"
>立即执行</el-button>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import * as projectApi from '@/api/project'
import * as systemApi from '@/api/system'
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
},
spider: {
type: String,
default: ''
},
options: {
type: String,
default: ''
}
})
const emit = defineEmits(['on-success', 'on-cancel'])
const formRef = ref<FormInstance>()
const submitting = ref(false)
interface OptionType {
label: string
value: string
}
const serverOptions = ref<OptionType[]>([])
const projectOptions = ref<OptionType[]>([])
const spiderOptions = ref<OptionType[]>([])
const form = reactive({
scrapyd_server_id: props.scrapydServerId || '',
project: props.project || '',
spider: props.spider || '',
options: props.options || '{}'
})
const rules = reactive({
scrapyd_server_id: [
{ required: true, message: '请选择服务器', trigger: 'change' }
],
project: [
{ required: true, message: '请选择项目', trigger: 'change' }
],
spider: [
{ required: true, message: '请选择爬虫', trigger: 'change' }
]
})
// 初始化数据
const initData = async () => {
await getServerOptions()
if (form.scrapyd_server_id) {
await getProjectOptions()
if (form.project) {
await getSpiderOptions()
}
}
}
// 获取服务器选项
const getServerOptions = async () => {
try {
const res = await systemApi.getScrapydServerList()
if (res.code === 0) {
serverOptions.value = res.data.map((server: any) => ({
label: server.server_name,
value: server.id
}))
}
} catch (error) {
console.error('获取服务器列表失败:', error)
}
}
// 获取项目选项
const getProjectOptions = async () => {
if (!form.scrapyd_server_id) return
try {
const res = await projectApi.listProjects({ scrapydServerId: form.scrapyd_server_id })
if (res.code === 0) {
projectOptions.value = res.data.map((project: string) => ({
label: project,
value: project
}))
}
} catch (error) {
console.error('获取项目列表失败:', error)
}
}
// 获取爬虫选项
const getSpiderOptions = async () => {
if (!form.scrapyd_server_id || !form.project) return
try {
const res = await projectApi.listSpiders({
scrapydServerId: form.scrapyd_server_id,
project: form.project
})
if (res.code === 0) {
spiderOptions.value = res.data.map((spider: string) => ({
label: spider,
value: spider
}))
}
} catch (error) {
console.error('获取爬虫列表失败:', error)
}
}
// 服务器变更处理
const handleServerChange = async () => {
form.project = ''
form.spider = ''
projectOptions.value = []
spiderOptions.value = []
await getProjectOptions()
}
// 项目变更处理
const handleProjectChange = async () => {
form.spider = ''
spiderOptions.value = []
await getSpiderOptions()
}
// 取消按钮
const handleCancel = () => {
emit('on-cancel')
}
// 提交表单
const submit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
// 解析可选参数
let parsedOptions = {}
if (form.options) {
try {
parsedOptions = JSON.parse(form.options)
} catch (e) {
ElMessage.error('其他参数格式错误,请检查JSON格式')
submitting.value = false
return
}
}
// 执行爬虫
const res = await projectApi.schedule({
scrapydServerId: form.scrapyd_server_id,
project: form.project,
spider: form.spider,
option: parsedOptions
})
if (res.code === 0) {
ElMessage({
type: 'success',
message: '任务提交成功: ' + res.data.jobid
})
emit('on-success')
}
} catch (error) {
console.error('提交执行任务失败:', error)
} finally {
submitting.value = false
}
}
})
}
onMounted(() => {
initData()
})
</script>
<template>
<el-button
size="small"
type="text"
@click="handleSchedule"
>
<el-icon><VideoPlay /></el-icon>
</el-button>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { VideoPlay } from '@element-plus/icons-vue'
import { schedule } from '@/api/project'
const props = defineProps({
spider: { type: String, default: '' },
project: { type: String, default: '' },
scrapydServerId: { type: String, default: '' },
options: { type: String, default: '' }
})
const emit = defineEmits(['success'])
// 处理运行爬虫
const handleSchedule = async () => {
try {
// 解析可选参数
let parsedOptions = {}
if (props.options) {
try {
parsedOptions = JSON.parse(props.options)
} catch (e) {
ElMessage.error('参数格式错误,请检查JSON格式')
return
}
}
const res = await schedule({
scrapydServerId: props.scrapydServerId,
project: props.project,
spider: props.spider,
option: parsedOptions
})
if (res.code === 0) {
ElMessage({
type: 'success',
message: '爬虫已启动: ' + res.data.jobid
})
emit('success')
} else {
ElMessage.error(res.msg || '运行失败')
}
} catch (error) {
console.error('运行爬虫失败:', error)
ElMessage.error('运行爬虫失败')
}
}
</script>
<style lang="scss" scoped>
</style>
<template>
<el-autocomplete
v-model="searchValue"
v-bind="$attrs"
:fetch-suggestions="querySearch"
placeholder="搜索爬虫名称"
clearable
@select="handleSelect"
>
<template #default="{ item }">
<span>{{ item.spider }}</span>
</template>
</el-autocomplete>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getSpiderList } from '@/api/project'
const props = defineProps({
modelValue: { type: String, default: '' },
project: { type: String, default: '' },
scrapydServerId: { type: String, default: '' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'change', 'on-init'])
const list = ref<any[]>([])
const searchValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 查询爬虫列表
const querySearch = (queryString: string, cb: (arg: any[]) => void) => {
const results = queryString
? list.value.filter(item => item.spider.toLowerCase().includes(queryString.toLowerCase()))
: list.value
cb(results)
}
// 选择爬虫
const handleSelect = (item: any) => {
emit('update:modelValue', item.spider)
emit('change', item.spider)
}
// 重置数据
const resetData = () => {
list.value = []
fetchSpiderList()
}
// 获取爬虫列表
const fetchSpiderList = async () => {
if (!props.scrapydServerId || !props.project) return
try {
const response = await getSpiderList({
scrapydServerId: props.scrapydServerId,
project: props.project
})
if (response.code === 0) {
list.value = response.data
emit('on-init', response.data)
}
} catch (error) {
console.error('获取爬虫列表失败:', error)
}
}
// 监听项目变化
watch(() => props.project, (val) => {
if (val) {
fetchSpiderList()
} else {
list.value = []
}
}, { immediate: true })
// 对外暴露方法
defineExpose({
resetData
})
</script>
<style lang="scss" scoped>
</style>
<template>
<div class="app-container">
<div class="page-header">
<el-button @click="goBack">
<el-icon><Back /></el-icon>返回
</el-button>
<h2>爬虫管理: {{ project }}</h2>
</div>
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>爬虫列表</span>
<div class="right-panel">
<el-input
v-model="searchQuery"
placeholder="搜索爬虫"
clearable
class="search-input"
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="refreshSpiderList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="filteredSpiderList" stripe border style="width: 100%">
<el-table-column label="序号" width="60" align="center">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="spider" label="爬虫名称" align="center"/>
<el-table-column label="调度日志" width="100" align="center">
<template #default="scope">
<router-link
:to="{
path: '/schedule-log/list',
query: {
project: project,
spider: scope.row.spider,
scrapydServerId: scrapydServerId
}
}"
target="_blank"
>
<el-button type="info" size="small">
<el-icon><Document /></el-icon>日志
</el-button>
</router-link>
</template>
</el-table-column>
<el-table-column label="运行统计" width="100" align="center">
<template #default="scope">
<router-link
:to="{
path: '/stats/list',
query: {
project: project,
spider: scope.row.spider,
scrapydServerId: scrapydServerId
}
}"
target="_blank"
>
<el-button type="warning" size="small">
<el-icon><Histogram /></el-icon>统计
</el-button>
</router-link>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button type="primary" size="small" @click="handleRunSpider(scope.row)">
<el-icon><VideoPlay /></el-icon>运行
</el-button>
<el-button type="success" size="small" @click="viewLogs(scope.row)">
<el-icon><Document /></el-icon>查看日志
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 运行爬虫对话框 -->
<el-dialog v-model="runDialogVisible" title="运行爬虫" width="500px">
<el-form :model="runForm" label-width="120px" :rules="rules" ref="runFormRef">
<el-form-item label="爬虫名称">
<el-input v-model="runForm.spider" disabled />
</el-form-item>
<el-form-item label="任务ID" prop="jobid">
<el-input v-model="runForm.jobid" placeholder="请输入任务ID" />
</el-form-item>
<el-form-item label="爬虫参数">
<el-input
v-model="runForm.spider_args"
type="textarea"
:rows="4"
placeholder="请输入爬虫参数,格式为: key=value key2=value2"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="runDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitRunSpider" :loading="submitLoading">确认</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getSpiderList, scheduleJob } from '@/api/project'
import { Back, Refresh, VideoPlay, Document, Search, Histogram } from '@element-plus/icons-vue'
import { v4 as uuidv4 } from 'uuid'
const route = useRoute()
const router = useRouter()
const project = ref('')
const scrapydServerId = ref('')
const loading = ref(false)
const spiderList = ref<any[]>([])
const runDialogVisible = ref(false)
const submitLoading = ref(false)
const searchQuery = ref('')
const runForm = ref({
spider: '',
jobid: '',
spider_args: ''
})
const runFormRef = ref()
// 根据搜索条件过滤爬虫列表
const filteredSpiderList = computed(() => {
if (!searchQuery.value) return spiderList.value
return spiderList.value.filter(item =>
item.spider.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
// 表单验证规则
const rules = {
jobid: [
{ required: true, message: '请输入任务ID', trigger: 'blur' }
]
}
// 处理搜索
const handleSearch = () => {
// 搜索已由计算属性处理
}
// 返回项目列表
const goBack = () => {
router.push('/project')
}
// 获取爬虫列表
const fetchSpiderList = async () => {
if (!project.value || !scrapydServerId.value) return
loading.value = true
try {
const response = await getSpiderList({
project: project.value,
scrapydServerId: scrapydServerId.value
})
spiderList.value = response.data
} catch (error) {
console.error('获取爬虫列表失败:', error)
ElMessage.error('获取爬虫列表失败')
} finally {
loading.value = false
}
}
// 刷新爬虫列表
const refreshSpiderList = () => {
fetchSpiderList()
}
// 运行爬虫
const handleRunSpider = (row: any) => {
runDialogVisible.value = true
runForm.value = {
spider: row.spider,
jobid: uuidv4(),
spider_args: ''
}
}
// 提交运行爬虫
const submitRunSpider = async () => {
if (!runFormRef.value) return
await runFormRef.value.validate(async (valid: any) => {
if (valid) {
submitLoading.value = true
try {
await scheduleJob({
project: project.value,
spider: runForm.value.spider,
scrapydServerId: scrapydServerId.value,
jobid: runForm.value.jobid,
spider_args: runForm.value.spider_args,
})
ElMessage.success('爬虫已启动')
runDialogVisible.value = false
} catch (error) {
console.error('启动爬虫失败:', error)
ElMessage.error('启动爬虫失败')
} finally {
submitLoading.value = false
}
}
})
}
// 查看日志
const viewLogs = (row: any) => {
router.push({
path: '/logs/spider',
query: {
project: project.value,
spider: row.spider,
scrapydServerId: scrapydServerId.value
}
})
}
onMounted(() => {
project.value = route.query.project as string
scrapydServerId.value = window.localStorage.getItem('scrapyd_server_id') || ''
if (!project.value) {
ElMessage.error('缺少项目参数')
router.push('/project')
return
}
if (!scrapydServerId.value) {
ElMessage.error('缺少服务器ID参数')
router.push('/project')
return
}
fetchSpiderList()
})
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 20px;
gap: 10px;
h2 {
margin: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.right-panel {
display: flex;
gap: 10px;
}
.search-input {
width: 200px;
}
.el-icon {
margin-right: 5px;
}
</style>
<template>
<div class="app-container">
<div class="page-header">
<el-button @click="goBack">
<el-icon><Back /></el-icon>返回
</el-button>
<h2>爬虫日志: {{ project }} / {{ spider }}</h2>
</div>
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>日志列表</span>
<div class="right-panel">
<el-button type="primary" @click="refreshLogList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="logList" stripe style="width: 100%">
<el-table-column prop="logfile" label="日志文件" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button type="primary" size="small" @click="viewLog(scope.row)">
查看日志内容
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 日志内容对话框 -->
<el-dialog v-model="logDialogVisible" :title="currentLog" width="80%" top="5vh">
<div v-loading="logLoading" class="log-content">
<pre>{{ logContent }}</pre>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getSpiderLogs, getJobLog } from '@/api/log'
import { Back, Refresh } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const project = ref('')
const spider = ref('')
const loading = ref(false)
const logList = ref([])
const logDialogVisible = ref(false)
const logLoading = ref(false)
const logContent = ref('')
const currentLog = ref('')
// 返回爬虫列表
const goBack = () => {
router.push({
path: '/spider',
query: { project: project.value }
})
}
// 获取日志列表
const fetchLogList = async () => {
if (!project.value || !spider.value) return
loading.value = true
try {
const response = await getSpiderLogs({
project: project.value,
spider: spider.value,
scrapydServerId: localStorage.getItem('scrapydServerId')!
})
logList.value = response.data.logs.map((logfile: any) => ({ logfile }))
} catch (error) {
console.error('获取日志列表失败:', error)
ElMessage.error('获取日志列表失败')
} finally {
loading.value = false
}
}
// 刷新日志列表
const refreshLogList = () => {
fetchLogList()
}
// 查看日志内容
const viewLog = async (row: any) => {
currentLog.value = row.logfile
logDialogVisible.value = true
logContent.value = ''
logLoading.value = true
try {
const response = await getJobLog({
project: project.value,
spider: spider.value,
job: row.logfile.replace('.log', ''),
scrapydServerId: localStorage.getItem('scrapydServerId')!
})
logContent.value = response.data.log || '无日志内容'
} catch (error) {
console.error('获取日志内容失败:', error)
ElMessage.error('获取日志内容失败')
logContent.value = '获取日志内容失败'
} finally {
logLoading.value = false
}
}
onMounted(() => {
project.value = route.query.project as string
spider.value = route.query.spider as string
if (!project.value || !spider.value) {
ElMessage.error('缺少必要参数')
router.push('/project')
return
}
fetchLogList()
})
</script>
<style scoped lang="scss">
.app-container {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 20px;
gap: 10px;
h2 {
margin: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.right-panel {
display: flex;
gap: 10px;
}
.log-content {
max-height: 70vh;
overflow-y: auto;
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
pre {
white-space: pre-wrap;
font-family: monospace;
margin: 0;
}
}
.el-icon {
margin-right: 5px;
}
</style>
<template>
<div class="stats-remove">
<el-button type="danger" @click="confirmRemove" :disabled="isDisabled">
{{ buttonText }}
</el-button>
</div>
</template>
<script lang="ts" setup>
import { computed, defineProps, defineEmits } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import * as statsApi from '@/api/stats'
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
},
spider: {
type: String,
default: ''
},
buttonText: {
type: String,
default: '删除选中'
}
})
const emit = defineEmits(['success'])
// 计算是否禁用按钮
const isDisabled = computed(() => {
return !props.scrapydServerId && !props.project && !props.spider
})
// 确认删除操作
const confirmRemove = () => {
ElMessageBox.confirm('确认删除所选的统计数据?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
interface ParamType {
scrapydServerId: string;
project?: string;
spider?: string;
[key: string]: string | undefined;
}
const params: ParamType = {
scrapydServerId: props.scrapydServerId,
project: props.project || undefined,
spider: props.spider || undefined
}
// 过滤掉空值
Object.keys(params).forEach((key) => {
if (!params[key]) {
delete params[key];
}
});
const res = await statsApi.removeStats(params)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功!'
})
emit('success')
}
} catch (error) {
console.error('删除统计数据失败:', error)
}
})
.catch(() => {
// 取消操作
})
}
</script>
<style lang="scss" scoped>
.stats-remove {
margin-left: 10px;
display: inline-block;
}
</style>
<template>
<div class="stats-table">
<el-table
v-bind="$attrs"
:data="data"
border
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
align="center"
label="序号"
width="60"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
label="项目名"
align="center"
width="150px"
prop="project"
>
<template #default="scope">
{{ scope.row.project || '-' }}
</template>
</el-table-column>
<el-table-column
label="Spider"
align="center"
prop="spider"
>
<template #default="scope">
{{ scope.row.spider }}
</template>
</el-table-column>
<el-table-column
label="收集数量"
align="center"
width="80px"
prop="item_scraped_count"
>
<template #default="scope">
{{ scope.row.item_scraped_count }}
</template>
</el-table-column>
<el-table-column
label="丢弃数量"
align="center"
width="80px"
prop="item_dropped_count"
>
<template #default="scope">
{{ scope.row.item_dropped_count }}
</template>
</el-table-column>
<el-table-column
label="错误日志"
align="center"
width="110px"
prop="log_error_count"
sortable="custom"
>
<template #default="scope">
{{ scope.row.log_error_count }}
</template>
</el-table-column>
<el-table-column
label="持续时间"
align="right"
width="110px"
prop="duration"
sortable="custom"
>
<template #default="scope">
{{ scope.row.duration_str }}
</template>
</el-table-column>
<el-table-column
label="开始时间"
align="center"
width="170px"
prop="start_time"
>
<template #default="scope">
{{ scope.row.start_time }}
</template>
</el-table-column>
<el-table-column
label="结束时间"
align="center"
width="170px"
prop="finish_time"
>
<template #default="scope">
{{ scope.row.finish_time }}
</template>
</el-table-column>
<el-table-column
label="操作"
align="center"
width="180px"
>
<template #default="scope">
<el-button
type="primary"
size="small"
@click="viewDetail(scope.row)"
>
详情
</el-button>
<el-button
type="danger"
size="small"
@click="confirmRemove(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
<el-table-column
label="运行日志"
align="center"
width="80px"
>
<template #default="scope">
<router-link
:to="{
name: 'SpiderLogs',
query: {
scrapydServerId: scope.row.scrapyd_server_id,
project: scope.row.project,
spider: scope.row.spider,
job: scope.row.spider_job_id
}
}"
target="_blank"
>
<el-icon><Document /></el-icon> 日志
</router-link>
</template>
</el-table-column>
</el-table>
<!-- 详情对话框 -->
<el-dialog
v-model="dialogVisible"
title="统计详情"
width="70%"
>
<div v-if="currentDetail" class="stats-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="项目名">{{ currentDetail.project }}</el-descriptions-item>
<el-descriptions-item label="爬虫名">{{ currentDetail.spider }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ currentDetail.start_time }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ currentDetail.finish_time }}</el-descriptions-item>
<el-descriptions-item label="收集数量">{{ currentDetail.item_scraped_count }}</el-descriptions-item>
<el-descriptions-item label="丢弃数量">{{ currentDetail.item_dropped_count }}</el-descriptions-item>
<el-descriptions-item label="持续时间">{{ currentDetail.duration_str }}</el-descriptions-item>
<el-descriptions-item label="结束原因">{{ currentDetail.finish_reason || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="detail-section" v-if="currentDetail.stats">
<h3>统计详情</h3>
<el-table :data="statsTableData" border style="width: 100%">
<el-table-column prop="key" label="指标名称" min-width="250px" />
<el-table-column prop="value" label="值" min-width="100px" />
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, defineProps, defineEmits, computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import * as statsApi from '@/api/stats'
const props = defineProps({
data: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['sort-change'])
const dialogVisible = ref(false)
const currentDetail = ref<Record<string, any> | null>(null)
// 统计数据表格展示
const statsTableData = computed(() => {
if (!currentDetail.value || !currentDetail.value.stats) return []
return Object.entries(currentDetail.value.stats)
.filter(([key]) => key !== 'spider' && key !== 'project') // 过滤掉已显示在上方的字段
.map(([key, value]) => ({
key,
value: String(value)
}))
.sort((a, b) => a.key.localeCompare(b.key))
})
// 查看详情
const viewDetail = async (row: Record<string, any>) => {
try {
const res = await statsApi.getStatsDetail({
scrapydServerId: row.scrapyd_server_id,
spider_job_id: row.spider_job_id
})
if (res.code === 0) {
currentDetail.value = res.data
dialogVisible.value = true
}
} catch (error) {
console.error('获取统计详情失败:', error)
}
}
// 确认删除
const confirmRemove = (row: Record<string, any>) => {
ElMessageBox.confirm('确认删除该统计记录?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await statsApi.removeStats({
scrapydServerId: row.scrapyd_server_id,
spider_job_id: row.spider_job_id
})
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功!'
})
emit('sort-change', { prop: '', order: '' }) // 触发刷新
}
} catch (error) {
console.error('删除统计记录失败:', error)
}
})
.catch(() => {
// 取消操作
})
}
// 排序变化
const handleSortChange = (val: { prop: string, order: string }) => {
emit('sort-change', val)
}
</script>
<style lang="scss" scoped>
.stats-table {
margin-bottom: 20px;
}
.stats-detail {
.detail-section {
margin-top: 20px;
h3 {
margin-bottom: 15px;
font-weight: 500;
font-size: 16px;
}
}
}
</style>
<template>
<div class="stats-tool">
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="服务器" size="default">
<select-scrapyd-server
v-model:value="serverValue"
@change="handleServerChange"
/>
</el-form-item>
<el-form-item size="default" label="项目">
<el-select
v-model="projectValue"
placeholder="请选择项目"
clearable
@change="handleProjectChange"
style="width: 180px"
size="default"
>
<el-option
v-for="item in projectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="爬虫" size="default">
<el-select
v-model="spiderValue"
placeholder="请选择爬虫"
clearable
size="default"
@change="handleSpiderChange"
style="width: 180px"
>
<el-option
v-for="item in spiderOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button type="danger" @click="confirmClear">清空数据</el-button>
<stats-remove
:scrapyd-server-id="serverValue"
:project="projectValue"
:spider="spiderValue"
button-text="批量删除"
@success="handleRemoveSuccess"
/>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ref, defineProps, defineEmits, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import SelectScrapydServer from '@/components/SelectScrapydServer.vue'
import StatsRemove from './StatsRemove.vue'
import * as projectApi from '@/api/project'
import * as statsApi from '@/api/stats'
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
},
spider: {
type: String,
default: ''
}
})
const emit = defineEmits([
'update:scrapyd-server-id',
'update:project',
'update:spider',
'project-change',
'spider-change',
'success'
])
interface OptionItem {
label: string
value: string
}
const serverValue = ref(props.scrapydServerId)
const projectValue = ref(props.project)
const spiderValue = ref(props.spider)
const projectOptions = ref<OptionItem[]>([])
const spiderOptions = ref<OptionItem[]>([])
// 获取项目列表
const getProjects = async () => {
if (!serverValue.value) return
try {
const res = await projectApi.listProjects({
scrapydServerId: serverValue.value
})
if (res.code === 0) {
projectOptions.value = (res.data || []).map((item: string) => ({
label: item,
value: item
}))
}
} catch (error) {
console.error('获取项目列表失败:', error)
}
}
// 获取爬虫列表
const getSpiders = async () => {
if (!serverValue.value || !projectValue.value) return
try {
const res = await projectApi.listSpiders({
scrapydServerId: serverValue.value,
project: projectValue.value
})
if (res.code === 0) {
spiderOptions.value = (res.data || []).map((item: string) => ({
label: item,
value: item
}))
}
} catch (error) {
console.error('获取爬虫列表失败:', error)
}
}
// 处理服务器变化
const handleServerChange = async (val: string) => {
serverValue.value = val
emit('update:scrapyd-server-id', val)
// 清空项目和爬虫选择
projectValue.value = ''
emit('update:project', '')
spiderValue.value = ''
emit('update:spider', '')
// 重新加载项目列表
await getProjects()
}
// 处理项目变化
const handleProjectChange = async (val: string) => {
projectValue.value = val
emit('update:project', val)
// 清空爬虫选择
spiderValue.value = ''
emit('update:spider', '')
// 重新加载爬虫列表
await getSpiders()
// 触发项目变化事件
emit('project-change')
}
// 处理爬虫变化
const handleSpiderChange = async (val: string) => {
spiderValue.value = val
emit('update:spider', val)
// 触发爬虫变化事件
emit('spider-change')
}
// 搜索按钮
const handleSearch = () => {
emit('success')
}
// 批量删除成功回调
const handleRemoveSuccess = () => {
emit('success')
}
// 确认清空统计数据
const confirmClear = () => {
ElMessageBox.confirm('确认清空所有统计数据?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await statsApi.clearAllStats({
scrapydServerId: serverValue.value,
project: projectValue.value,
spider: spiderValue.value
})
if (res.code === 0) {
ElMessage({
type: 'success',
message: '清空成功!'
})
emit('success')
}
} catch (error) {
console.error('清空统计数据失败:', error)
}
})
.catch(() => {
// 取消操作
})
}
// 监听属性变化
watch(() => props.scrapydServerId, (val) => {
serverValue.value = val
})
watch(() => props.project, (val) => {
projectValue.value = val
})
watch(() => props.spider, (val) => {
spiderValue.value = val
})
onMounted(async () => {
if (serverValue.value) {
await getProjects()
if (projectValue.value) {
await getSpiders()
}
}
})
</script>
<style lang="scss" scoped>
.stats-tool {
margin-bottom: 20px;
text-align: left;
}
</style>
<template>
<div class="app-container">
<!-- 卡片包装器 -->
<el-card class="stats-card">
<template #header>
<div class="card-header">
<span>爬虫统计数据</span>
</div>
</template>
<!-- 工具栏组件 -->
<stats-tool
v-model:scrapyd-server-id="scrapydServerId"
v-model:project="project"
v-model:spider="spider"
@project-change="handleProjectChange"
@spider-change="handleSpiderChange"
@success="handleSuccess"
/>
<!-- 统计数据表格 -->
<stats-table
:data="list"
v-loading="listLoading"
@sort-change="handleSortChange"
/>
<!-- 分页控件 -->
<div class="pagination-container">
<el-config-provider :locale="zhCn">
<el-pagination
v-model:currentPage="page"
v-model:page-size="size"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
/>
</el-config-provider>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import StatsTable from '@/views/stats/components/StatsTable.vue'
import StatsTool from '@/views/stats/components/StatsTool.vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as statsApi from '@/api/stats'
const list = ref([])
const total = ref(0)
const listLoading = ref(false)
const page = ref(1)
const size = ref(20)
// 查询参数
const project = ref('')
const spider = ref('')
const scrapydServerId = ref('')
// 排序
const orderProp = ref('')
const orderType = ref('')
const getData = async () => {
listLoading.value = true
try {
const res = await statsApi.getStatsList({
page: page.value,
size: size.value,
scrapydServerId: scrapydServerId.value,
project: project.value,
spider: spider.value,
order_prop: orderProp.value,
order_type: orderType.value
})
if (res.code === 0) {
list.value = res.data.list || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('获取爬虫统计数据失败:', error)
} finally {
listLoading.value = false
}
}
const handleProjectChange = () => {
page.value = 1
getData()
}
const handleSpiderChange = () => {
page.value = 1
getData()
}
const handleSuccess = () => {
page.value = 1
getData()
}
const handleSortChange = ({ prop, order }: { prop: string, order: string }) => {
orderProp.value = prop
orderType.value = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
getData()
}
const handleSizeChange = (val: number) => {
size.value = val
getData()
}
const handleCurrentChange = (val: number) => {
page.value = val
getData()
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
.stats-card {
margin-bottom: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
}
}
.pagination-container {
padding: 15px 0;
text-align: right;
}
</style>
<template>
<h1>system index</h1>
</template>
<script setup lang="ts">
import { ref } from 'vue';
</script>
<template>
<div class="app-container">
<h1>测试页面</h1>
<p>这是一个用于测试侧边栏功能的页面。</p>
</div>
</template>
<script setup lang="ts">
// 测试页面
</script>
<style scoped>
.app-container {
padding: 20px;
}
</style>
<template>
<div class="app-container">
<h1>子页面1</h1>
<p>这是第一个子页面,用于测试多级菜单。</p>
<el-button type="primary">测试按钮</el-button>
</div>
</template>
<script setup lang="ts">
// 子页面1
</script>
<style scoped>
.app-container {
padding: 20px;
}
</style>
<template>
<div class="app-container">
<h1>子页面2</h1>
<p>这是第二个子页面,用于测试多级菜单。</p>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="date" label="日期" width="180" />
<el-table-column prop="name" label="名称" width="180" />
<el-table-column prop="address" label="地址" />
</el-table>
</div>
</template>
<script setup lang="ts">
// 子页面2
const tableData = [
{
date: '2023-05-13',
name: '测试数据1',
address: '上海市普陀区金沙江路 1518 弄'
},
{
date: '2023-05-14',
name: '测试数据2',
address: '上海市普陀区金沙江路 1517 弄'
}
]
</script>
<style scoped>
.app-container {
padding: 20px;
}
</style>
/// <reference types="vite/client" />
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'unocss'
export default defineConfig({
// ...UnoCSS options
})
\ No newline at end of file
import { defineConfig } from 'vite'
import UnoCSS from 'unocss/vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
UnoCSS()
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "@/styles/_variables.scss" as *;
@use "@/styles/_mixin.scss" as *;
`
}
}
},
server: {
host: '0.0.0.0',
port: 9529,
proxy: {
// 代理API请求,使用更精确的路径匹配
'/api': {
target: 'http://127.0.0.1:5001/',
changeOrigin: true,
},
}
}
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment