first commit
This commit is contained in:
22
frontend/.eslintrc.json
Normal file
22
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-unused-vars": "warn",
|
||||
"semi": ["error", "never"],
|
||||
"quotes": ["error", "single"],
|
||||
"indent": ["error", 2],
|
||||
"comma-dangle": ["error", "never"]
|
||||
}
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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?
|
||||
9
frontend/.prettierrc.json
Normal file
9
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 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 IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.3",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
391
frontend/src/App.vue
Normal file
391
frontend/src/App.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<script setup>
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="side-menu"
|
||||
router
|
||||
>
|
||||
<div class="logo-section">
|
||||
<div class="logo">🌸</div>
|
||||
<div class="logo-text">代理池</div>
|
||||
</div>
|
||||
|
||||
<el-menu-item index="/dashboard">
|
||||
<template #title>
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span>总览</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/proxies">
|
||||
<template #title>
|
||||
<span class="menu-icon">📋</span>
|
||||
<span>代理列表</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/crawler">
|
||||
<template #title>
|
||||
<span class="menu-icon">🎀</span>
|
||||
<span>任务管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/plugins">
|
||||
<template #title>
|
||||
<span class="menu-icon">🔌</span>
|
||||
<span>插件管理</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/settings">
|
||||
<template #title>
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span>设置</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<div class="main-content">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
/* 全局样式修正 */
|
||||
:root {
|
||||
--menu-bg: #FFFFFF;
|
||||
--menu-text: #666666;
|
||||
--menu-active-text: #FF6B9D;
|
||||
--menu-hover-bg: #FFF0F5;
|
||||
--menu-border: #FFB6C1;
|
||||
--theme-bg: #FAFAFA;
|
||||
--theme-bg-card: #FFFFFF;
|
||||
--theme-border: #FFE4EC;
|
||||
--theme-primary: #FF6B9D;
|
||||
--theme-text: #333333;
|
||||
--theme-text-secondary: #999999;
|
||||
--theme-bg-light: #FFF9FB;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(255, 107, 157, 0.15);
|
||||
box-shadow: 4px 0 20px rgba(255, 107, 157, 0.1);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 35px 0;
|
||||
border-bottom: 1px solid rgba(255, 107, 157, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-section::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #FF6B9D, transparent);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 52px;
|
||||
margin-bottom: 10px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #FF6B9D;
|
||||
text-shadow: 0 0 20px rgba(255, 107, 157, 0.3);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
background-color: transparent;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
border-radius: 12px;
|
||||
margin: 8px 12px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--theme-text-secondary);
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: var(--theme-primary);
|
||||
transform: scaleY(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover) {
|
||||
background: rgba(0, 212, 255, 0.1) !important;
|
||||
color: var(--theme-primary);
|
||||
transform: translateX(8px);
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover::before) {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: linear-gradient(135deg, #00D4FF 0%, #00B8E0 100%) !important;
|
||||
color: var(--theme-bg) !important;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active::before) {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
/* 全局覆盖Element Plus黑色边框 */
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px var(--theme-border) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px var(--theme-primary) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--theme-primary) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper) {
|
||||
box-shadow: 0 0 0 1px var(--theme-border) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px var(--theme-primary) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper.is-focused) {
|
||||
box-shadow: 0 0 0 1px var(--theme-primary) inset !important;
|
||||
}
|
||||
|
||||
:deep(.el-input-number__decrease),
|
||||
:deep(.el-input-number__increase) {
|
||||
background: var(--theme-bg-light);
|
||||
color: var(--theme-text-secondary);
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-input-number__decrease:hover),
|
||||
:deep(.el-input-number__increase:hover) {
|
||||
background: rgba(255, 107, 157, 0.1);
|
||||
color: var(--theme-primary);
|
||||
border-color: var(--theme-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-input-number__decrease.is-disabled),
|
||||
:deep(.el-input-number__increase.is-disabled) {
|
||||
color: #ccc !important;
|
||||
border-color: var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%) !important;
|
||||
border-color: #FF6B9D !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--success) {
|
||||
background: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%) !important;
|
||||
border-color: #00D4FF !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--warning) {
|
||||
background: linear-gradient(135deg, #FFB800 0%, #FFD000 100%) !important;
|
||||
border-color: #FFB800 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--danger) {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%) !important;
|
||||
border-color: #FF6B6B !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-card) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
box-shadow: 0 2px 12px rgba(255, 107, 157, 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
background: var(--theme-bg-light) !important;
|
||||
color: var(--theme-text) !important;
|
||||
border-bottom: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table td.el-table__cell) {
|
||||
border-bottom: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__border-left) {
|
||||
border-left: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__border-right) {
|
||||
border-right: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__inner:hover) {
|
||||
border-color: var(--theme-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: var(--theme-primary) !important;
|
||||
border-color: var(--theme-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__input.is-disabled .el-checkbox__inner) {
|
||||
background: #f5f5f5 !important;
|
||||
border-color: #e4e7ed !important;
|
||||
}
|
||||
|
||||
:deep(.el-pagination button) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
background: var(--theme-bg-light) !important;
|
||||
color: var(--theme-text-secondary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-pagination button:hover) {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: var(--theme-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-pagination li.is-active) {
|
||||
background: var(--theme-primary) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.el-pager li) {
|
||||
background: var(--theme-bg-light) !important;
|
||||
color: var(--theme-text-secondary) !important;
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
}
|
||||
|
||||
/* 下拉面板样式 */
|
||||
:deep(.el-select-dropdown) {
|
||||
border: 1px solid var(--theme-border) !important;
|
||||
box-shadow: 0 2px 12px rgba(255, 107, 157, 0.1) !important;
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item) {
|
||||
color: var(--theme-text) !important;
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item:hover) {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: var(--theme-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item.is-selected) {
|
||||
color: var(--theme-primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/api/index.js
Normal file
52
frontend/src/api/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios'
|
||||
import { showError } from '../utils/message'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8923',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response.data,
|
||||
error => {
|
||||
console.error('API请求错误:', error)
|
||||
showError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const statsAPI = {
|
||||
getStats: () => api.get('/api/stats')
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
getProxies: (params) => api.post('/api/proxies', params),
|
||||
getRandomProxy: () => api.get('/api/proxies/random'),
|
||||
getProxyDetail: (ip, port) => api.get(`/api/proxies/${ip}/${port}`),
|
||||
deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`),
|
||||
batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }),
|
||||
cleanInvalidProxies: () => api.delete('/api/proxies/clean-invalid'),
|
||||
exportProxies: (format, protocol) => api.get(`/api/proxies/export/${format}`, {
|
||||
params: { protocol },
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export const crawlerAPI = {
|
||||
start: (numValidators = 50) => api.post('/api/crawler/start', { num_validators: numValidators }),
|
||||
stop: () => api.post('/api/crawler/stop'),
|
||||
getStatus: () => api.get('/api/crawler/status')
|
||||
}
|
||||
|
||||
export const schedulerAPI = {
|
||||
setScheduler: (enabled, intervalMinutes = 60) => api.post('/api/scheduler', { enabled, interval_minutes: intervalMinutes }),
|
||||
getStatus: () => api.get('/api/scheduler')
|
||||
}
|
||||
|
||||
export const pluginsAPI = {
|
||||
getPlugins: () => api.get('/api/plugins'),
|
||||
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
|
||||
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`)
|
||||
}
|
||||
|
||||
export default api
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 496 B |
43
frontend/src/components/HelloWorld.vue
Normal file
43
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/components/PageHeader.vue
Normal file
36
frontend/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<el-card class="header-card" shadow="hover">
|
||||
<h1 class="title">{{ icon }} {{ title }} {{ icon }}</h1>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '📄'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
color: var(--theme-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
155
frontend/src/components/ProtocolChart.vue
Normal file
155
frontend/src/components/ProtocolChart.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<el-card class="chart-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📈 协议分布</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const chartData = computed(() => [
|
||||
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: '#00D4FF' } },
|
||||
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: '#00A8CC' } },
|
||||
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: '#7B68EE' } },
|
||||
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: '#FF3366' } }
|
||||
])
|
||||
|
||||
const total = computed(() => chartData.value.reduce((sum, item) => sum + item.value, 0))
|
||||
|
||||
function getChartOption() {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
|
||||
return `${params.name}: ${params.value} (${percent}%)`
|
||||
},
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderColor: '#FF6B9D',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#333',
|
||||
fontSize: 14
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: '#666',
|
||||
fontSize: 14
|
||||
},
|
||||
itemGap: 20
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 8,
|
||||
borderColor: '#FFFFFF',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
formatter: '{b}\n{c}个'
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(255, 107, 157, 0.2)'
|
||||
}
|
||||
},
|
||||
animationType: 'scale',
|
||||
animationEasing: 'elasticOut',
|
||||
animationDelay: (idx) => Math.random() * 200,
|
||||
data: chartData.value
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chartInstance) return
|
||||
chartInstance.setOption(getChartOption(), true)
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
chartInstance?.resize()
|
||||
}
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartInstance?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
border-radius: 20px;
|
||||
min-height: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 107, 157, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 350px;
|
||||
}
|
||||
</style>
|
||||
114
frontend/src/components/QuickActions.vue
Normal file
114
frontend/src/components/QuickActions.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<el-card class="chart-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🎯 快速操作</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="action-btn"
|
||||
:loading="loading"
|
||||
@click="$emit('start-crawler')"
|
||||
>
|
||||
<span class="btn-icon">🚀</span>
|
||||
立即更新
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
class="action-btn"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<span class="btn-icon">📥</span>
|
||||
导出代理
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="large"
|
||||
class="action-btn"
|
||||
@click="$emit('clean')"
|
||||
>
|
||||
<span class="btn-icon">🧹</span>
|
||||
清理无效
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['start-crawler', 'export', 'clean'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
border-radius: 20px;
|
||||
min-height: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
font-size: 16px;
|
||||
border-radius: 14px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.25);
|
||||
}
|
||||
|
||||
:deep(.action-btn .el-button__content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
98
frontend/src/components/StatCard.vue
Normal file
98
frontend/src/components/StatCard.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<el-card :class="['stat-card', type]" shadow="hover">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">{{ icon }}</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ value }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
border-radius: 20px;
|
||||
min-height: 180px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 107, 157, 0.15);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 8px 24px rgba(255, 107, 157, 0.15);
|
||||
border-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.stat-card.total {
|
||||
background-color: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.available {
|
||||
background-color: rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.new {
|
||||
background-color: rgba(255, 184, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.score {
|
||||
background-color: rgba(168, 85, 247, 0.1);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 48px;
|
||||
margin-right: 20px;
|
||||
filter: drop-shadow(0 0 15px rgba(255, 107, 157, 0.3));
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-secondary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
76
frontend/src/composables/useWebSocket.js
Normal file
76
frontend/src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useWebSocket() {
|
||||
const ws = ref(null)
|
||||
const isExplicitDisconnect = ref(false)
|
||||
let reconnectTimer = null
|
||||
|
||||
function connect(url, onMessage, onError, onClose, onOpen, token) {
|
||||
isExplicitDisconnect.value = false
|
||||
|
||||
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket已经连接啦~')
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = token ? `${url}?token=${token}` : url
|
||||
console.log('尝试连接WebSocket:', wsUrl)
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket连接成功啦~', ws.value.readyState)
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
onOpen?.()
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
onMessage?.(data)
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error)
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log('WebSocket连接关闭:', event.code, event.reason)
|
||||
ws.value = null
|
||||
|
||||
onClose?.(event)
|
||||
|
||||
if (!isExplicitDisconnect.value) {
|
||||
console.log('检测到异常断开,3秒后尝试重连...')
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect(url, onMessage, onError, onClose, onOpen)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
isExplicitDisconnect.value = true
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
connect,
|
||||
disconnect
|
||||
}
|
||||
}
|
||||
17
frontend/src/main.js
Normal file
17
frontend/src/main.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import './styles/element-plus.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
40
frontend/src/router/index.js
Normal file
40
frontend/src/router/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/proxies',
|
||||
name: 'ProxyList',
|
||||
component: () => import('../views/ProxyList.vue')
|
||||
},
|
||||
{
|
||||
path: '/crawler',
|
||||
name: 'CrawlerTasks',
|
||||
component: () => import('../views/CrawlerTasks.vue')
|
||||
},
|
||||
{
|
||||
path: '/plugins',
|
||||
name: 'Plugins',
|
||||
component: () => import('../views/Plugins.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
144
frontend/src/stores/crawler.js
Normal file
144
frontend/src/stores/crawler.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { crawlerAPI, schedulerAPI } from '../api'
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
|
||||
export const useCrawlerStore = defineStore('crawler', () => {
|
||||
const running = ref(false)
|
||||
const stats = ref({})
|
||||
const scheduled = ref(false)
|
||||
const intervalMinutes = ref(60)
|
||||
const progress = ref({
|
||||
total: 0,
|
||||
current: 0,
|
||||
success: 0,
|
||||
failed: 0
|
||||
})
|
||||
const statusMessage = ref('')
|
||||
|
||||
const { connect, disconnect } = useWebSocket()
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await crawlerAPI.getStatus()
|
||||
if (response.code === 200) {
|
||||
running.value = response.data.running
|
||||
stats.value = response.data.stats || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取爬虫状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startCrawler(numValidators = 50) {
|
||||
try {
|
||||
const response = await crawlerAPI.start(numValidators)
|
||||
if (response.code === 200) {
|
||||
running.value = true
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动爬虫失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function stopCrawler() {
|
||||
try {
|
||||
const response = await crawlerAPI.stop()
|
||||
if (response.code === 200) {
|
||||
running.value = false
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止爬虫失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchSchedulerStatus() {
|
||||
try {
|
||||
const response = await schedulerAPI.getStatus()
|
||||
if (response.code === 200) {
|
||||
scheduled.value = response.data.enabled
|
||||
intervalMinutes.value = response.data.interval_minutes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取定时任务状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function setScheduler(enabled, interval = 60) {
|
||||
try {
|
||||
const response = await schedulerAPI.setScheduler(enabled, interval)
|
||||
if (response.code === 200) {
|
||||
scheduled.value = enabled
|
||||
intervalMinutes.value = interval
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('设置定时任务失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const wsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8923'
|
||||
const token = import.meta.env.VITE_API_KEY
|
||||
|
||||
connect(
|
||||
`${wsUrl}/ws`,
|
||||
(data) => {
|
||||
console.log('收到WebSocket消息:', data)
|
||||
if (data.type === 'progress') {
|
||||
console.log('更新进度:', data.data)
|
||||
progress.value = {
|
||||
found: data.data.found || 0,
|
||||
verified: data.data.verified || 0,
|
||||
success_rate: data.data.success_rate || 0
|
||||
}
|
||||
console.log('进度更新后:', progress.value)
|
||||
} else if (data.type === 'status') {
|
||||
statusMessage.value = data.data.message
|
||||
if (data.data.status === 'completed') {
|
||||
running.value = false
|
||||
} else if (data.data.status === 'stopped') {
|
||||
running.value = false
|
||||
} else if (data.data.status === 'running') {
|
||||
running.value = true
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('WebSocket错误:', error)
|
||||
},
|
||||
(event) => {
|
||||
console.log('WebSocket连接关闭:', event.code, event.reason)
|
||||
},
|
||||
() => {
|
||||
console.log('WebSocket连接成功啦~')
|
||||
},
|
||||
token
|
||||
)
|
||||
}
|
||||
|
||||
function disconnectWebSocket() {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
return {
|
||||
running,
|
||||
stats,
|
||||
scheduled,
|
||||
intervalMinutes,
|
||||
progress,
|
||||
statusMessage,
|
||||
fetchStatus,
|
||||
startCrawler,
|
||||
stopCrawler,
|
||||
fetchSchedulerStatus,
|
||||
setScheduler,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket
|
||||
}
|
||||
})
|
||||
58
frontend/src/stores/plugins.js
Normal file
58
frontend/src/stores/plugins.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { pluginsAPI } from '../api'
|
||||
|
||||
export const usePluginsStore = defineStore('plugins', () => {
|
||||
const plugins = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchPlugins() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pluginsAPI.getPlugins()
|
||||
if (response.code === 200) {
|
||||
plugins.value = response.data.plugins || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取插件列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(pluginId, enabled) {
|
||||
try {
|
||||
const response = await pluginsAPI.togglePlugin(pluginId, enabled)
|
||||
if (response.code === 200) {
|
||||
const plugin = plugins.value.find(p => p.id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = enabled
|
||||
}
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换插件状态失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function crawlPlugin(pluginId) {
|
||||
try {
|
||||
const response = await pluginsAPI.crawlPlugin(pluginId)
|
||||
if (response.code === 200) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('触发插件爬取失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
loading,
|
||||
fetchPlugins,
|
||||
togglePlugin,
|
||||
crawlPlugin
|
||||
}
|
||||
})
|
||||
108
frontend/src/stores/proxy.js
Normal file
108
frontend/src/stores/proxy.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { proxiesAPI, statsAPI } from '../api'
|
||||
|
||||
export const useProxyStore = defineStore('proxy', () => {
|
||||
const proxies = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const stats = ref({})
|
||||
|
||||
const availableCount = computed(() => stats.value.available || 0)
|
||||
const totalCount = computed(() => stats.value.total || 0)
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await statsAPI.getStats()
|
||||
if (response.code === 200) {
|
||||
stats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProxies(params) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await proxiesAPI.getProxies(params)
|
||||
if (response.code === 200) {
|
||||
proxies.value = response.data.list
|
||||
total.value = response.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代理列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProxy(ip, port) {
|
||||
try {
|
||||
const response = await proxiesAPI.deleteProxy(ip, port)
|
||||
if (response.code === 200) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除代理失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function batchDeleteProxies(proxyList) {
|
||||
try {
|
||||
const response = await proxiesAPI.batchDeleteProxies(proxyList)
|
||||
if (response.code === 200) {
|
||||
return response.data.deleted_count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除代理失败:', error)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function cleanInvalidProxies() {
|
||||
try {
|
||||
const response = await proxiesAPI.cleanInvalidProxies()
|
||||
if (response.code === 200) {
|
||||
return response.data.deleted_count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理无效代理失败:', error)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function exportProxies(format, protocol) {
|
||||
try {
|
||||
const response = await proxiesAPI.exportProxies(format, protocol)
|
||||
const url = window.URL.createObjectURL(new Blob([response]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', `proxies.${format}`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('导出代理失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
proxies,
|
||||
total,
|
||||
loading,
|
||||
stats,
|
||||
availableCount,
|
||||
totalCount,
|
||||
fetchStats,
|
||||
fetchProxies,
|
||||
deleteProxy,
|
||||
batchDeleteProxies,
|
||||
cleanInvalidProxies,
|
||||
exportProxies
|
||||
}
|
||||
})
|
||||
569
frontend/src/style.css
Normal file
569
frontend/src/style.css
Normal file
@@ -0,0 +1,569 @@
|
||||
:root {
|
||||
--theme-primary: #00D4FF;
|
||||
--theme-primary-light: #00B8E0;
|
||||
--theme-primary-dark: #0090B0;
|
||||
--theme-bg: linear-gradient(135deg, #0A0E27 0%, #1A1F3A 50%, #162032 100%);
|
||||
--theme-bg-solid: #0A0E27;
|
||||
--theme-bg-light: #1A1F3A;
|
||||
--theme-bg-card: rgba(26, 31, 58, 0.95);
|
||||
--theme-text: #E0E6FF;
|
||||
--theme-text-secondary: #9CA3AF;
|
||||
--theme-border: #2D3748;
|
||||
--theme-border-light: #3A4558;
|
||||
--theme-gradient-1: linear-gradient(135deg, #00D4FF 0%, #00B8E0 100%);
|
||||
--theme-gradient-2: linear-gradient(135deg, #FF6B9D 0%, #FF8E53 100%);
|
||||
--theme-gradient-3: linear-gradient(135deg, #00FF88 0%, #00CC6A 100%);
|
||||
|
||||
--el-color-primary: #00D4FF;
|
||||
--el-color-primary-light-3: #00B8E0;
|
||||
--el-color-primary-light-5: #4A90E2;
|
||||
--el-color-primary-light-7: #00B8FF;
|
||||
--el-color-primary-light-8: #00D4FF;
|
||||
--el-color-primary-light-9: #00E5FF;
|
||||
--el-color-primary-dark-2: #0090B0;
|
||||
--el-color-success: #00FF88;
|
||||
--el-color-warning: #FFB800;
|
||||
--el-color-danger: #FF3366;
|
||||
--el-color-info: #A855F7;
|
||||
--el-bg-color: #0A0E27;
|
||||
--el-bg-color-page: #0A0E27;
|
||||
--el-text-color-primary: #E0E6FF;
|
||||
--el-text-color-regular: #9CA3AF;
|
||||
--el-border-color: #2D3748;
|
||||
--el-border-color-light: #2D3748;
|
||||
--el-fill-color-blank: #1A1F3A;
|
||||
--el-fill-color-light: #1A1F3A;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--theme-text);
|
||||
background: var(--theme-bg);
|
||||
background-attachment: fixed;
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--theme-primary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--theme-primary-light);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: var(--theme-bg-card);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
box-shadow: 0 8px 32px rgba(0, 212, 255, 0.2);
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: var(--theme-gradient-1);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: #0A0E27;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-button--primary::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.el-button--primary:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
background: var(--theme-gradient-3);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: #0A0E27;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-button--success::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.el-button--success:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.el-button--success:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: var(--theme-gradient-2);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-button--danger::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.el-button--danger:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.el-button--danger:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 157, 0.4);
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
background-color: #FFB800;
|
||||
border-color: #FFB800;
|
||||
border-radius: 8px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: #0A0E27;
|
||||
}
|
||||
|
||||
.el-button--warning:hover {
|
||||
background-color: #E5A600;
|
||||
border-color: #E5A600;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3);
|
||||
}
|
||||
|
||||
.el-button--default {
|
||||
border: 1px solid var(--theme-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-text);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-button--default:hover {
|
||||
border-color: var(--theme-primary);
|
||||
color: var(--theme-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px var(--theme-border) inset;
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--theme-bg-light);
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--theme-primary-light) inset;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px var(--theme-primary) inset;
|
||||
}
|
||||
|
||||
.el-select .el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-border);
|
||||
background-color: var(--theme-bg-card);
|
||||
}
|
||||
|
||||
.el-table th {
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-primary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--theme-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.el-table td {
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
background-color: var(--theme-bg-card);
|
||||
}
|
||||
|
||||
.el-table tr:hover > td {
|
||||
background-color: var(--theme-bg-light);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--theme-border);
|
||||
padding: 4px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.el-tag--primary {
|
||||
background-color: rgba(0, 212, 255, 0.15);
|
||||
color: var(--theme-primary);
|
||||
border-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background-color: rgba(0, 255, 136, 0.15);
|
||||
color: #00FF88;
|
||||
border-color: #00FF88;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background-color: rgba(255, 184, 0, 0.15);
|
||||
color: #FFB800;
|
||||
border-color: #FFB800;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background-color: rgba(255, 51, 102, 0.15);
|
||||
color: #FF3366;
|
||||
border-color: #FF3366;
|
||||
}
|
||||
|
||||
.el-tag--info {
|
||||
background-color: rgba(168, 85, 247, 0.15);
|
||||
color: #A855F7;
|
||||
border-color: #A855F7;
|
||||
}
|
||||
|
||||
.el-rate__icon {
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.el-pagination.is-background .el-pager li:not(.is-disabled).is-active {
|
||||
background-color: var(--theme-primary);
|
||||
color: #0A0E27;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-pagination.is-background .btn-next,
|
||||
.el-pagination.is-background .btn-prev {
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-primary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--theme-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-pagination.is-background .btn-next:hover,
|
||||
.el-pagination.is-background .btn-prev:hover {
|
||||
background-color: var(--theme-primary);
|
||||
color: #0A0E27;
|
||||
border-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.el-pagination.is-background .el-pager li {
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-primary);
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--theme-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-pagination.is-background .el-pager li:hover {
|
||||
background-color: var(--theme-primary-light);
|
||||
color: #0A0E27;
|
||||
}
|
||||
|
||||
.el-message {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
background-color: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.el-message--success .el-message__content {
|
||||
color: #00FF88;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-message--error .el-message__content {
|
||||
color: #FF3366;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-message--warning .el-message__content {
|
||||
color: #FFB800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-message--info .el-message__content {
|
||||
color: #A855F7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-progress-bar__inner {
|
||||
background: var(--theme-gradient-1);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-progress-bar__inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: progressShine 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes progressShine {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.el-switch.is-checked .el-switch__core {
|
||||
background-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.el-switch.is-checked .el-switch__action {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background-color: var(--theme-bg-card);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.el-alert--success {
|
||||
background-color: rgba(0, 255, 136, 0.1);
|
||||
border-color: #00FF88;
|
||||
}
|
||||
|
||||
.el-alert--info {
|
||||
background-color: rgba(168, 85, 247, 0.1);
|
||||
border-color: #A855F7;
|
||||
}
|
||||
|
||||
.el-alert--warning {
|
||||
background-color: rgba(255, 184, 0, 0.1);
|
||||
border-color: #FFB800;
|
||||
}
|
||||
|
||||
.el-alert--error {
|
||||
background-color: rgba(255, 51, 102, 0.1);
|
||||
border-color: #FF3366;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
background-color: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
background-color: var(--theme-bg-light);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
color: var(--theme-primary);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.el-dropdown-menu {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-border);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--theme-bg-card);
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:hover {
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-primary);
|
||||
border-radius: 6px;
|
||||
margin: 2px 6px;
|
||||
}
|
||||
|
||||
.el-notification {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.el-notification__title {
|
||||
color: var(--theme-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
padding: 16px 20px;
|
||||
background-color: var(--theme-bg-light);
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 20px;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: var(--theme-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-input-number {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-input-number .el-input__wrapper {
|
||||
border-radius: 8px;
|
||||
background-color: var(--theme-bg-light);
|
||||
}
|
||||
|
||||
.el-radio-button__inner {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid var(--theme-border);
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-text);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
background-color: var(--theme-primary);
|
||||
border-color: var(--theme-primary);
|
||||
color: #0A0E27;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: 1px solid var(--theme-border);
|
||||
background-color: var(--theme-bg);
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
border-radius: 8px;
|
||||
margin: 4px 8px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background-color: var(--theme-bg-light);
|
||||
color: var(--theme-primary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--theme-primary);
|
||||
color: #0A0E27;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--theme-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--theme-border-light);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--theme-primary-light);
|
||||
}
|
||||
377
frontend/src/styles/element-plus.css
Normal file
377
frontend/src/styles/element-plus.css
Normal file
@@ -0,0 +1,377 @@
|
||||
/* Element Plus 全局样式覆盖 - 强制去除所有黑色边框 */
|
||||
|
||||
/* 输入框 */
|
||||
.el-input__wrapper {
|
||||
box-shadow: 0 0 0 1px #FFE4EC inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 */
|
||||
.el-select__wrapper {
|
||||
box-shadow: 0 0 0 1px #FFE4EC inset !important;
|
||||
}
|
||||
|
||||
.el-select__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
.el-select__wrapper.is-focused {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
.el-select__placeholder {
|
||||
color: #999999 !important;
|
||||
}
|
||||
|
||||
.el-select__caret {
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
box-shadow: 0 2px 12px rgba(255, 107, 157, 0.1) !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item {
|
||||
color: #333333 !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item:hover {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.is-selected {
|
||||
color: #FF6B9D !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 数字输入框 */
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
background: #FFF9FB !important;
|
||||
color: #999999 !important;
|
||||
border: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-input-number__decrease:hover,
|
||||
.el-input-number__increase:hover {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: #FF6B9D !important;
|
||||
border-color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-input-number__decrease.is-disabled,
|
||||
.el-input-number__increase.is-disabled {
|
||||
color: #cccccc !important;
|
||||
border-color: #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-input-number__wrapper {
|
||||
box-shadow: 0 0 0 1px #FFE4EC inset !important;
|
||||
}
|
||||
|
||||
.el-input-number__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
.el-input-number__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px #FF6B9D inset !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.el-button {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%) !important;
|
||||
border-color: #FF6B9D !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background: linear-gradient(135deg, #FF5A8F 0%, #FF7FA7 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-button--success {
|
||||
background: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%) !important;
|
||||
border-color: #00D4FF !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--success:hover {
|
||||
background: linear-gradient(135deg, #00C4F0 0%, #00D4E8 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
background: linear-gradient(135deg, #FFB800 0%, #FFD000 100%) !important;
|
||||
border-color: #FFB800 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--warning:hover {
|
||||
background: linear-gradient(135deg, #FFA700 0%, #FFC000 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%) !important;
|
||||
border-color: #FF6B6B !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--danger:hover {
|
||||
background: linear-gradient(135deg, #FF5A5A 0%, #FF7A7A 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.el-card {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
box-shadow: 0 2px 12px rgba(255, 107, 157, 0.08) !important;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.el-table {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: #FFF9FB !important;
|
||||
color: #333333 !important;
|
||||
border-bottom: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
border-bottom: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-table__border-left {
|
||||
border-left: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-table__border-right {
|
||||
border-right: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-table tr:hover > td {
|
||||
background: #FFF0F5 !important;
|
||||
}
|
||||
|
||||
.el-table__body tr.current-row > td.el-table__cell {
|
||||
background: #FFE4EC !important;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.el-checkbox__inner {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.el-checkbox__inner:hover {
|
||||
border-color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background: #FF6B9D !important;
|
||||
border-color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-checkbox__input.is-disabled .el-checkbox__inner {
|
||||
background: #f5f5f5 !important;
|
||||
border-color: #e4e7ed !important;
|
||||
}
|
||||
|
||||
/* 分页器 */
|
||||
.el-pagination button {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
background: #FFF9FB !important;
|
||||
color: #999999 !important;
|
||||
}
|
||||
|
||||
.el-pagination button:hover {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-pagination li.is-active {
|
||||
background: #FF6B9D !important;
|
||||
color: white !important;
|
||||
border-color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-pager li {
|
||||
background: #FFF9FB !important;
|
||||
color: #999999 !important;
|
||||
border: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-pager li:hover {
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
.el-tag {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-tag--primary {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: #FF6B9D !important;
|
||||
border-color: rgba(255, 107, 157, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background: rgba(0, 212, 255, 0.1) !important;
|
||||
color: #00D4FF !important;
|
||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background: rgba(255, 184, 0, 0.1) !important;
|
||||
color: #FFB800 !important;
|
||||
border-color: rgba(255, 184, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background: rgba(255, 107, 107, 0.1) !important;
|
||||
color: #FF6B6B !important;
|
||||
border-color: rgba(255, 107, 107, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Rate 评分 */
|
||||
.el-rate__icon {
|
||||
color: #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-rate__icon.hover {
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
/* Dialog 对话框 */
|
||||
.el-dialog {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
border-bottom: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
/* Dropdown 下拉菜单 */
|
||||
.el-dropdown-menu {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
box-shadow: 0 2px 12px rgba(255, 107, 157, 0.1) !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
color: #333333 !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:hover {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
/* Scrollbar 滚动条 */
|
||||
.el-scrollbar__wrap::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.el-scrollbar__wrap::-webkit-scrollbar-thumb {
|
||||
background: #FFE4EC;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.el-scrollbar__wrap::-webkit-scrollbar-thumb:hover {
|
||||
background: #FF6B9D;
|
||||
}
|
||||
|
||||
/* Form 表单 */
|
||||
.el-form-item__label {
|
||||
color: #666666 !important;
|
||||
}
|
||||
|
||||
.el-form-item__error {
|
||||
color: #FF6B6B !important;
|
||||
}
|
||||
|
||||
/* Message 消息提示 */
|
||||
.el-message {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 157, 0.15) !important;
|
||||
}
|
||||
|
||||
.el-message--success {
|
||||
background: rgba(0, 212, 255, 0.1) !important;
|
||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
||||
color: #00D4FF !important;
|
||||
}
|
||||
|
||||
.el-message--error {
|
||||
background: rgba(255, 107, 107, 0.1) !important;
|
||||
border-color: rgba(255, 107, 107, 0.3) !important;
|
||||
color: #FF6B6B !important;
|
||||
}
|
||||
|
||||
.el-message--warning {
|
||||
background: rgba(255, 184, 0, 0.1) !important;
|
||||
border-color: rgba(255, 184, 0, 0.3) !important;
|
||||
color: #FFB800 !important;
|
||||
}
|
||||
|
||||
.el-message--info {
|
||||
background: rgba(255, 107, 157, 0.1) !important;
|
||||
border-color: rgba(255, 107, 157, 0.3) !important;
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
/* MessageBox 弹窗 */
|
||||
.el-message-box {
|
||||
border: 1px solid #FFE4EC !important;
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 157, 0.15) !important;
|
||||
}
|
||||
|
||||
.el-message-box__header {
|
||||
border-bottom: 1px solid #FFE4EC !important;
|
||||
}
|
||||
|
||||
.el-message-box__title {
|
||||
color: #FF6B9D !important;
|
||||
}
|
||||
|
||||
.el-message-box__content {
|
||||
color: #333333 !important;
|
||||
}
|
||||
|
||||
.el-message-box__btns {
|
||||
border-top: 1px solid #FFE4EC !important;
|
||||
}
|
||||
36
frontend/src/utils/message.js
Normal file
36
frontend/src/utils/message.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export const showSuccess = (message) => {
|
||||
ElMessage.success(message)
|
||||
}
|
||||
|
||||
export const showError = (error) => {
|
||||
let message = '操作失败啦~'
|
||||
|
||||
if (error) {
|
||||
if (typeof error === 'string') {
|
||||
message = error
|
||||
} else if (error.response) {
|
||||
const { data, status } = error.response
|
||||
if (data && data.message) {
|
||||
message = data.message
|
||||
} else if (data && data.error) {
|
||||
message = data.error
|
||||
} else {
|
||||
message = `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.message) {
|
||||
message = error.message
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
}
|
||||
|
||||
export const showWarning = (message) => {
|
||||
ElMessage.warning(message)
|
||||
}
|
||||
|
||||
export const showInfo = (message) => {
|
||||
ElMessage.info(message)
|
||||
}
|
||||
421
frontend/src/views/CrawlerTasks.vue
Normal file
421
frontend/src/views/CrawlerTasks.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="crawler-tasks">
|
||||
<PageHeader title="任务管理" icon="🎀" />
|
||||
|
||||
<el-card class="control-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🎮 任务控制</span>
|
||||
<el-tag :type="crawler.running ? 'success' : 'info'" size="large">
|
||||
{{ crawler.running ? '运行中' : '已停止' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="control-content">
|
||||
<div class="control-item">
|
||||
<label class="control-label">验证并发数</label>
|
||||
<el-input-number
|
||||
v-model="numValidators"
|
||||
:min="10"
|
||||
:max="200"
|
||||
:step="10"
|
||||
size="large"
|
||||
class="control-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleStart"
|
||||
:loading="crawler.running"
|
||||
:disabled="crawler.running"
|
||||
class="start-btn"
|
||||
>
|
||||
<span class="btn-icon">🚀</span>
|
||||
开始任务
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="large"
|
||||
@click="handleStop"
|
||||
:disabled="!crawler.running"
|
||||
class="stop-btn"
|
||||
>
|
||||
<span class="btn-icon">⏹️</span>
|
||||
停止任务
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📊 任务进度</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="progress-content">
|
||||
<div class="progress-item">
|
||||
<div class="progress-label">爬取进度</div>
|
||||
<el-progress
|
||||
:percentage="crawlProgress"
|
||||
:stroke-width="24"
|
||||
class="progress-bar"
|
||||
color="#FF6B9D"
|
||||
>
|
||||
<span class="progress-text">成功率 {{ crawler.progress.success_rate }}%</span>
|
||||
</el-progress>
|
||||
</div>
|
||||
|
||||
<div class="progress-item">
|
||||
<div class="progress-label">验证统计</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item success">
|
||||
<span class="stat-label">发现</span>
|
||||
<span class="stat-value">{{ crawler.progress.found }}</span>
|
||||
</div>
|
||||
<div class="stat-item verified">
|
||||
<span class="stat-label">验证通过</span>
|
||||
<span class="stat-value">{{ crawler.progress.verified }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-box">
|
||||
<div class="status-item">
|
||||
<span class="status-label">状态</span>
|
||||
<span class="status-value">{{ crawler.statusMessage || '等待中...' }}</span>
|
||||
</div>
|
||||
<div class="status-item" v-if="crawler.stats.start_time">
|
||||
<span class="status-label">开始时间</span>
|
||||
<span class="status-value">{{ formatTime(crawler.stats.start_time) }}</span>
|
||||
</div>
|
||||
<div class="status-item" v-if="crawler.stats.plugins?.length">
|
||||
<span class="status-label">加载插件</span>
|
||||
<span class="status-value">{{ crawler.stats.plugins.length }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="scheduled-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">⏰ 定时任务</span>
|
||||
<el-switch
|
||||
v-model="crawler.scheduled"
|
||||
@change="handleSchedulerChange"
|
||||
size="large"
|
||||
active-color="#FF6B9D"
|
||||
inactive-color="#dcdfe6"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scheduled-content">
|
||||
<div class="scheduled-item">
|
||||
<label class="scheduled-label">执行间隔(分钟)</label>
|
||||
<el-input-number
|
||||
v-model="crawler.intervalMinutes"
|
||||
:min="10"
|
||||
:max="1440"
|
||||
:step="10"
|
||||
size="large"
|
||||
:disabled="!crawler.scheduled"
|
||||
class="scheduled-input"
|
||||
@change="handleIntervalChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="scheduled-info">
|
||||
<el-alert
|
||||
:title="crawler.scheduled ? '定时任务已启用' : '定时任务已停用'"
|
||||
:type="crawler.scheduled ? 'success' : 'info'"
|
||||
:description="crawler.scheduled ? `每 ${crawler.intervalMinutes} 分钟自动执行一次爬取任务~` : '开启定时任务可以自动定期更新代理池哦~'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useCrawlerStore } from '../stores/crawler'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
const crawler = useCrawlerStore()
|
||||
const numValidators = ref(50)
|
||||
|
||||
const crawlProgress = computed(() => {
|
||||
if (!crawler.running || crawler.progress.total === 0) return 0
|
||||
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
|
||||
})
|
||||
|
||||
const verifyProgress = computed(() => {
|
||||
if (crawler.progress.total === 0) return 0
|
||||
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
|
||||
})
|
||||
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return '-'
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const success = await crawler.startCrawler(numValidators.value)
|
||||
if (success) {
|
||||
ElMessage.success('爬虫任务开始啦~')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
const success = await crawler.stopCrawler()
|
||||
if (success) {
|
||||
ElMessage.success('爬虫任务已停止~')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSchedulerChange(enabled) {
|
||||
const success = await crawler.setScheduler(enabled, crawler.intervalMinutes)
|
||||
if (success) {
|
||||
ElMessage.success(enabled ? '定时任务已启动~' : '定时任务已停止~')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntervalChange() {
|
||||
if (crawler.scheduled) {
|
||||
const success = await crawler.setScheduler(true, crawler.intervalMinutes)
|
||||
if (success) {
|
||||
ElMessage.success(`定时任务间隔已更新为 ${crawler.intervalMinutes} 分钟~`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await crawler.fetchStatus()
|
||||
await crawler.fetchSchedulerStatus()
|
||||
crawler.connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
crawler.disconnectWebSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crawler-tasks {
|
||||
padding: 20px;
|
||||
background: var(--theme-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.control-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.control-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-right: 20px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.control-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.start-btn, .stop-btn {
|
||||
padding: 15px 40px;
|
||||
font-size: 16px;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.start-btn:hover:not(:disabled) {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.3);
|
||||
}
|
||||
|
||||
.stop-btn:hover:not(:disabled) {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.progress-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--theme-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-box {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: #FFF0F5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 16px;
|
||||
color: #FF6B9D;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 2px solid #34D399;
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 2px solid #EF4444;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-item.success .stat-value {
|
||||
color: #34D399;
|
||||
}
|
||||
|
||||
.stat-item.failed .stat-value {
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.scheduled-card {
|
||||
border-radius: 16px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.scheduled-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.scheduled-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.scheduled-label {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-right: 20px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.scheduled-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.scheduled-info {
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
209
frontend/src/views/Dashboard.vue
Normal file
209
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<PageHeader title="代理池管理系统" icon="🔮" />
|
||||
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<StatCard type="total" icon="📊" :value="stats.total || 0" label="总代理数" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<StatCard type="available" icon="✨" :value="stats.available || 0" label="可用数量" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<StatCard type="new" icon="🎉" :value="stats.today_new || 0" label="今日新增" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<StatCard type="score" icon="⭐" :value="(stats.avg_score || 0).toFixed(1)" label="平均分数" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<el-col :span="16">
|
||||
<ProtocolChart :data="stats" />
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<QuickActions :loading="crawler.running" @start-crawler="handleStartCrawler" @export="handleExport" @clean="handleClean" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="status-card" shadow="hover" v-if="crawler.running">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">🔄 当前任务状态</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="status-content">
|
||||
<el-progress
|
||||
:percentage="progressPercentage"
|
||||
:stroke-width="20"
|
||||
class="progress-bar"
|
||||
>
|
||||
<span class="progress-text">
|
||||
发现 {{ crawler.progress.found }} 个,验证通过 {{ crawler.progress.verified }} 个,成功率 {{ crawler.progress.success_rate }}%
|
||||
</span>
|
||||
</el-progress>
|
||||
<div class="status-message">{{ crawler.statusMessage }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import { useCrawlerStore } from '../stores/crawler'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ProtocolChart from '../components/ProtocolChart.vue'
|
||||
import QuickActions from '../components/QuickActions.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
const proxyStore = useProxyStore()
|
||||
const crawler = useCrawlerStore()
|
||||
|
||||
const stats = computed(() => proxyStore.stats)
|
||||
|
||||
// 监听爬虫状态,任务结束时自动刷新数据
|
||||
watch(() => crawler.running, async (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
await proxyStore.fetchStats()
|
||||
initCharts()
|
||||
ElMessage.success('任务完成,数据已更新~')
|
||||
}
|
||||
})
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (crawler.progress.total === 0) return 0
|
||||
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
|
||||
})
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
async function handleStartCrawler() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要开始爬取代理吗?这可能需要一些时间哦~', '提示', {
|
||||
confirmButtonText: '开始吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
const success = await crawler.startCrawler(50)
|
||||
if (success) {
|
||||
ElMessage.success('爬虫任务开始啦~')
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const success = await proxyStore.exportProxies('txt')
|
||||
if (success) {
|
||||
ElMessage.success('代理导出成功啦~')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClean() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清理所有无效代理吗?', '提示', {
|
||||
confirmButtonText: '清理吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const deletedCount = await proxyStore.cleanInvalidProxies()
|
||||
if (deletedCount >= 0) {
|
||||
ElMessage.success(`清理了 ${deletedCount} 个无效代理啦~`)
|
||||
await proxyStore.fetchStats()
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await proxyStore.fetchStats()
|
||||
await crawler.fetchStatus()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshData()
|
||||
crawler.connectWebSocket()
|
||||
|
||||
refreshTimer = setInterval(refreshData, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
crawler.disconnectWebSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
background: var(--theme-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: 20px;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 107, 157, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
border-color: rgba(255, 107, 157, 0.4);
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 157, 0.2);
|
||||
}
|
||||
|
||||
.status-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: #FF6B9D;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 10px rgba(255, 107, 157, 0.3);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #9CA3AF;
|
||||
padding: 15px;
|
||||
background: rgba(26, 31, 58, 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
209
frontend/src/views/Plugins.vue
Normal file
209
frontend/src/views/Plugins.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="plugins">
|
||||
<PageHeader title="插件管理" icon="🔌" />
|
||||
|
||||
<el-card class="plugins-card" shadow="hover" v-loading="pluginsStore.loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📦 插件列表</span>
|
||||
<el-button type="primary" @click="handleRefresh" size="large">
|
||||
<span class="btn-icon">🔄</span>
|
||||
刷新列表
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="pluginsStore.plugins" stripe>
|
||||
<el-table-column prop="name" label="插件名称" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="plugin-name">
|
||||
<span class="plugin-icon">🔌</span>
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="plugin-description">{{ row.description }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
@change="(val) => handleToggle(row.id, val)"
|
||||
active-color="#FF6B9D"
|
||||
inactive-color="#dcdfe6"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="统计" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="plugin-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">成功</span>
|
||||
<span class="stat-value success">{{ row.success_count }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">失败</span>
|
||||
<span class="stat-value failed">{{ row.failure_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="last_run" label="最后运行" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="last-run">{{ formatTime(row.last_run) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleCrawl(row.id)"
|
||||
:loading="crawlingPlugin === row.id"
|
||||
>
|
||||
<span class="btn-icon">🚀</span>
|
||||
立即爬取
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { usePluginsStore } from '../stores/plugins'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
const pluginsStore = usePluginsStore()
|
||||
const crawlingPlugin = ref(null)
|
||||
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return '从未运行'
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
await pluginsStore.fetchPlugins()
|
||||
ElMessage.success('插件列表已刷新~')
|
||||
}
|
||||
|
||||
async function handleToggle(pluginId, enabled) {
|
||||
const success = await pluginsStore.togglePlugin(pluginId, enabled)
|
||||
if (success) {
|
||||
ElMessage.success(enabled ? '插件已启用~' : '插件已禁用~')
|
||||
} else {
|
||||
await pluginsStore.fetchPlugins()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCrawl(pluginId) {
|
||||
try {
|
||||
crawlingPlugin.value = pluginId
|
||||
const success = await pluginsStore.crawlPlugin(pluginId)
|
||||
if (success) {
|
||||
ElMessage.success('插件开始爬取啦~')
|
||||
}
|
||||
} finally {
|
||||
crawlingPlugin.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await pluginsStore.fetchPlugins()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plugins {
|
||||
padding: 20px;
|
||||
background: var(--theme-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.plugins-card {
|
||||
border-radius: 16px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.plugin-description {
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.plugin-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: #34D399;
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.last-run {
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-switch__label) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
332
frontend/src/views/ProxyList.vue
Normal file
332
frontend/src/views/ProxyList.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="proxy-list">
|
||||
<PageHeader title="代理列表" icon="📋" />
|
||||
|
||||
<el-card class="filter-card" shadow="hover">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<el-form-item label="协议类型">
|
||||
<el-select v-model="filterForm.protocol" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="HTTP" value="http"></el-option>
|
||||
<el-option label="HTTPS" value="https"></el-option>
|
||||
<el-option label="SOCKS4" value="socks4"></el-option>
|
||||
<el-option label="SOCKS5" value="socks5"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低分数">
|
||||
<el-input-number v-model="filterForm.minScore" :min="0" :max="10" style="width: 120px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序方式">
|
||||
<el-select v-model="filterForm.sortBy" style="width: 140px">
|
||||
<el-option label="更新时间" value="last_check"></el-option>
|
||||
<el-option label="分数" value="score"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" class="search-btn">
|
||||
<span class="btn-icon">🔍</span>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset" class="reset-btn">
|
||||
<span class="btn-icon">🔄</span>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="table-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">代理详情</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete" :disabled="selectedProxies.length === 0">
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-dropdown @command="handleExport" split-button type="success">
|
||||
导出
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="txt">TXT格式</el-dropdown-item>
|
||||
<el-dropdown-item command="csv">CSV格式</el-dropdown-item>
|
||||
<el-dropdown-item command="json">JSON格式</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="proxyStore.proxies"
|
||||
style="width: 100%"
|
||||
v-loading="proxyStore.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
:row-style="{ cursor: 'pointer' }"
|
||||
class="proxy-table"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="ip" label="IP地址" width="150" />
|
||||
<el-table-column prop="port" label="端口" width="100" />
|
||||
<el-table-column prop="protocol" label="协议" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getProtocolType(scope.row.protocol)" effect="light">
|
||||
{{ scope.row.protocol.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="score" label="分数" width="100">
|
||||
<template #default="scope">
|
||||
<el-rate
|
||||
:model-value="scope.row.score || 0"
|
||||
disabled
|
||||
show-score
|
||||
:score-template="scope.row.score ? '{value}' : '0'"
|
||||
text-color="var(--theme-primary)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_check" label="最后检查时间" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="handleCopy(scope.row)"
|
||||
class="action-btn"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click.stop="handleDelete(scope.row)"
|
||||
class="action-btn"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="proxyStore.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
class="pagination"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
const proxyStore = useProxyStore()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedProxies = ref([])
|
||||
|
||||
const filterForm = reactive({
|
||||
protocol: '',
|
||||
minScore: 0,
|
||||
sortBy: 'last_check',
|
||||
sortOrder: 'DESC'
|
||||
})
|
||||
|
||||
function getProtocolType(protocol) {
|
||||
const types = {
|
||||
http: 'primary',
|
||||
https: 'success',
|
||||
socks4: 'warning',
|
||||
socks5: 'danger'
|
||||
}
|
||||
return types[protocol] || 'info'
|
||||
}
|
||||
|
||||
async function fetchProxies() {
|
||||
await proxyStore.fetchProxies({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
protocol: filterForm.protocol || undefined,
|
||||
min_score: filterForm.minScore,
|
||||
sort_by: filterForm.sortBy,
|
||||
sort_order: filterForm.sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterForm.protocol = ''
|
||||
filterForm.minScore = 0
|
||||
filterForm.sortBy = 'last_check'
|
||||
currentPage.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
function handleSelectionChange(selection) {
|
||||
selectedProxies.value = selection.map(item => [item.ip, item.port])
|
||||
}
|
||||
|
||||
async function handleCopy(proxy) {
|
||||
const text = `${proxy.ip}:${proxy.port}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(`已复制 ${text} 到剪贴板啦~`)
|
||||
} catch {
|
||||
ElMessage.error('复制失败呢~')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(proxy) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除代理 ${proxy.ip}:${proxy.port} 吗?`, '提示', {
|
||||
confirmButtonText: '删除吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
|
||||
if (success) {
|
||||
ElMessage.success('删除成功啦~')
|
||||
fetchProxies()
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedProxies.value.length} 个代理吗?`, '提示', {
|
||||
confirmButtonText: '删除吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
|
||||
if (deletedCount > 0) {
|
||||
ElMessage.success(`批量删除成功啦~共删除了 ${deletedCount} 个代理`)
|
||||
selectedProxies.value = []
|
||||
fetchProxies()
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format) {
|
||||
const success = await proxyStore.exportProxies(format, filterForm.protocol || undefined)
|
||||
if (success) {
|
||||
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功啦~`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange(size) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
function handleCurrentChange(page) {
|
||||
currentPage.value = page
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProxies()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-list {
|
||||
padding: 20px;
|
||||
background: var(--theme-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 16px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-btn, .reset-btn {
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-btn:hover, .reset-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.2);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #FF6B9D;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proxy-table {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
200
frontend/src/views/Settings.vue
Normal file
200
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<PageHeader title="设置" icon="⚙️" />
|
||||
|
||||
<el-card class="settings-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📝 关于</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="about-content">
|
||||
<div class="about-item">
|
||||
<span class="about-label">项目名称</span>
|
||||
<span class="about-value">代理池管理系统</span>
|
||||
</div>
|
||||
<div class="about-item">
|
||||
<span class="about-label">版本号</span>
|
||||
<span class="about-value">v1.0.0</span>
|
||||
</div>
|
||||
<div class="about-item">
|
||||
<span class="about-label">后端API</span>
|
||||
<span class="about-value">http://localhost:3000</span>
|
||||
</div>
|
||||
<div class="about-item">
|
||||
<span class="about-label">前端服务</span>
|
||||
<span class="about-value">http://localhost:8080</span>
|
||||
</div>
|
||||
<div class="about-item">
|
||||
<span class="about-label">数据库</span>
|
||||
<span class="about-value">SQLite</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
padding: 20px;
|
||||
background: var(--theme-bg);
|
||||
min-height: 100vh;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--theme-bg-card);
|
||||
border: 1px solid var(--theme-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.settings-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 157, 0.15);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background-color: var(--theme-bg-light);
|
||||
border-radius: 8px;
|
||||
padding: 20px 10px;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--theme-primary);
|
||||
border: none;
|
||||
color: var(--theme-bg);
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--theme-bg-light);
|
||||
border: 1px solid var(--theme-border);
|
||||
color: var(--theme-text);
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.about-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.about-item:hover {
|
||||
background-color: var(--theme-bg-light);
|
||||
border-radius: 8px;
|
||||
padding: 15px 10px;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.about-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.about-label {
|
||||
font-size: 16px;
|
||||
color: var(--theme-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.about-value {
|
||||
font-size: 16px;
|
||||
color: var(--theme-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 6173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user