重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件
主要变更: - 后端代码从根目录迁移到 app/ 目录 - 前端代码从 frontend/ 重命名为 WebUI/ - 更新所有导入路径以适配新结构 - 提取公共 API 响应函数到 app/api/common.py - 精简验证器服务代码 - 更新启动脚本和文档 测试: - 新增完整测试套件 (tests/) - 单元测试: 模型、仓库层 - 集成测试: 覆盖所有 22+ API 端点 - E2E 测试: 4个完整工作流场景 - 添加 pytest 配置和测试运行脚本
This commit is contained in:
22
WebUI/.eslintrc.json
Normal file
22
WebUI/.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
WebUI/.gitignore
vendored
Normal file
24
WebUI/.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
WebUI/.prettierrc.json
Normal file
9
WebUI/.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
13
WebUI/index.html
Normal file
13
WebUI/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>代理池管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
WebUI/package.json
Normal file
28
WebUI/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
WebUI/public/vite.svg
Normal file
1
WebUI/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 |
256
WebUI/src/App.vue
Normal file
256
WebUI/src/App.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<aside class="sidebar">
|
||||
<div class="logo-section">
|
||||
<el-icon class="logo" :size="40"><Grid /></el-icon>
|
||||
<h1 class="logo-text">代理池</h1>
|
||||
</div>
|
||||
|
||||
<nav class="menu-nav">
|
||||
<router-link
|
||||
v-for="item in menuItems"
|
||||
:key="item.index"
|
||||
:to="item.index"
|
||||
:class="['menu-item', { active: isActive(item.index) }]"
|
||||
>
|
||||
<el-icon class="menu-icon" :size="18">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span class="menu-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
House,
|
||||
Document,
|
||||
Connection,
|
||||
Setting,
|
||||
Grid
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const menuItems = [
|
||||
{ index: '/dashboard', icon: House, label: '总览' },
|
||||
{ index: '/proxies', icon: Document, label: '代理列表' },
|
||||
{ index: '/plugins', icon: Connection, label: '插件管理' },
|
||||
{ index: '/settings', icon: Setting, label: '设置' }
|
||||
]
|
||||
|
||||
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 侧边栏 - 冷灰紫风格 */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: var(--primary);
|
||||
margin-right: 12px;
|
||||
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-nav {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin: 4px 0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 悬停状态 */
|
||||
.menu-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 激活状态 - 紫底 + 左条 */
|
||||
.menu-item.active {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background: var(--primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 页面过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* 响应式 - 平板 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.logo-text,
|
||||
.menu-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式 - 手机 */
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-nav {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menu-item::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
WebUI/src/api/index.js
Normal file
100
WebUI/src/api/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import axios from 'axios'
|
||||
import { showError } from '../utils/message'
|
||||
|
||||
/** @type {string} 默认 API 基础 URL */
|
||||
export const DEFAULT_API_BASE_URL = 'http://localhost:9949'
|
||||
|
||||
/** @type {number} 请求超时时间(毫秒) */
|
||||
export const REQUEST_TIMEOUT = 30000
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
|
||||
timeout: REQUEST_TIMEOUT
|
||||
})
|
||||
|
||||
/**
|
||||
* 从 Blob 解析 JSON 错误响应
|
||||
* @param {Blob} blob
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
async function parseBlobError(blob) {
|
||||
try {
|
||||
const text = await blob.text()
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
// 处理 Blob 类型的错误响应
|
||||
if (error.response?.data instanceof Blob) {
|
||||
const parsedData = await parseBlobError(error.response.data)
|
||||
if (parsedData) {
|
||||
error.response.data = parsedData
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API请求错误:', error)
|
||||
showError(error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== API 模块 ====================
|
||||
|
||||
function cleanParams(params) {
|
||||
const cleaned = {}
|
||||
Object.keys(params).forEach((key) => {
|
||||
const value = params[key]
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
cleaned[key] = value
|
||||
}
|
||||
})
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export const statsAPI = {
|
||||
getStats: () => api.get('/api/proxies/stats')
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
getProxies: (params, signal) =>
|
||||
api.post('/api/proxies', cleanParams(params), signal ? { signal } : {}),
|
||||
|
||||
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 ? { protocol } : {},
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export const pluginsAPI = {
|
||||
getPlugins: () => api.get('/api/plugins'),
|
||||
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
|
||||
getPluginConfig: (pluginId) => api.get(`/api/plugins/${pluginId}/config`),
|
||||
updatePluginConfig: (pluginId, config) => api.post(`/api/plugins/${pluginId}/config`, { config }),
|
||||
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`),
|
||||
crawlAll: () => api.post('/api/plugins/crawl-all')
|
||||
}
|
||||
|
||||
export const schedulerAPI = {
|
||||
start: () => api.post('/api/scheduler/start'),
|
||||
stop: () => api.post('/api/scheduler/stop'),
|
||||
validateNow: () => api.post('/api/scheduler/validate-now'),
|
||||
getStatus: () => api.get('/api/scheduler/status')
|
||||
}
|
||||
|
||||
export const settingsAPI = {
|
||||
getSettings: () => api.get('/api/settings'),
|
||||
saveSettings: (data) => api.post('/api/settings', data)
|
||||
}
|
||||
|
||||
export default api
|
||||
81
WebUI/src/components/PageHeader.vue
Normal file
81
WebUI/src/components/PageHeader.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<el-card class="header-card" shadow="hover">
|
||||
<h1 class="title">
|
||||
<el-icon v-if="icon" :size="24" class="title-icon">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
<span class="title-text">{{ title }}</span>
|
||||
</h1>
|
||||
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 页面标题组件 - 冷灰紫主题
|
||||
* @description 统一的页面头部展示
|
||||
*/
|
||||
defineProps({
|
||||
/** 页面标题 */
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** 图标组件或组件名称 */
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
},
|
||||
/** 副标题 */
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.3));
|
||||
}
|
||||
|
||||
.title-text {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
228
WebUI/src/components/ProtocolChart.vue
Normal file
228
WebUI/src/components/ProtocolChart.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<el-card class="chart-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><PieChart /></el-icon>
|
||||
协议分布
|
||||
</span>
|
||||
<el-tooltip content="显示各协议类型的代理数量分布">
|
||||
<el-icon class="help-icon"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="chartRef" class="chart-container" v-loading="!hasData">
|
||||
<el-empty v-if="!hasData" description="暂无数据" :image-size="80" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { InfoFilled, PieChart } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps({
|
||||
/** 统计数据 */
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
let resizeTimer = null
|
||||
const cachedColors = ref(null)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const hasData = computed(() => {
|
||||
const { http_count, https_count, socks4_count, socks5_count } = props.data
|
||||
return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!cachedColors.value) return []
|
||||
const colors = cachedColors.value
|
||||
return [
|
||||
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: colors.info } },
|
||||
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: colors.success } },
|
||||
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: colors.primary } },
|
||||
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: colors.warning } }
|
||||
].filter(item => item.value > 0)
|
||||
})
|
||||
|
||||
const total = computed(() =>
|
||||
chartData.value.reduce((sum, item) => sum + item.value, 0)
|
||||
)
|
||||
|
||||
// ==================== 方法 ====================
|
||||
function loadColors() {
|
||||
if (cachedColors.value) return cachedColors.value
|
||||
|
||||
const getCssVar = (name, fallback) =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
|
||||
|
||||
cachedColors.value = {
|
||||
primary: getCssVar('--primary', '#927CFF'),
|
||||
success: getCssVar('--success', '#22C55E'),
|
||||
warning: getCssVar('--warning', '#F59E0B'),
|
||||
info: getCssVar('--info', '#38BDF8'),
|
||||
textPrimary: getCssVar('--text-primary', '#F5F7FA'),
|
||||
textSecondary: getCssVar('--text-secondary', '#A5AEBD'),
|
||||
surface: getCssVar('--surface', '#181C25')
|
||||
}
|
||||
return cachedColors.value
|
||||
}
|
||||
|
||||
function getChartOption() {
|
||||
const colors = cachedColors.value
|
||||
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(24, 28, 37, 0.95)',
|
||||
borderColor: colors.primary,
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 14
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13
|
||||
},
|
||||
itemGap: 16
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '65%'],
|
||||
center: ['38%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: colors.surface,
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
formatter: '{b}\n{c}个'
|
||||
},
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(146, 124, 255, 0.3)'
|
||||
}
|
||||
},
|
||||
animationType: 'scale',
|
||||
animationEasing: 'elasticOut',
|
||||
animationDelay: (idx) => Math.random() * 200,
|
||||
data: chartData.value
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!chartRef.value || !hasData.value) return
|
||||
|
||||
loadColors()
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
updateChart()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chartInstance || !hasData.value) return
|
||||
chartInstance.setOption(getChartOption(), true)
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
chartInstance?.resize()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
}
|
||||
chartInstance?.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
// ==================== 监听 ====================
|
||||
watch(() => props.data, () => {
|
||||
if (!chartInstance && hasData.value) {
|
||||
initChart()
|
||||
} else {
|
||||
updateChart()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(() => {
|
||||
if (hasData.value) {
|
||||
initChart()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-card {
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chart-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.help-icon:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 350px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
144
WebUI/src/components/QuickActions.vue
Normal file
144
WebUI/src/components/QuickActions.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<el-card class="actions-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><Lightning /></el-icon>
|
||||
快速操作
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<button
|
||||
class="action-btn btn-success"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<span class="btn-content">
|
||||
<el-icon class="btn-icon"><Download /></el-icon>
|
||||
<span class="btn-text">导出代理</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="action-btn btn-warning"
|
||||
@click="$emit('clean')"
|
||||
>
|
||||
<span class="btn-content">
|
||||
<el-icon class="btn-icon"><Delete /></el-icon>
|
||||
<span class="btn-text">清理无效</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
|
||||
|
||||
defineEmits(['export', 'clean'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions-card {
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.actions-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #0F1117;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #2DD4BF;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
color: #0F1117;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #FBBF24;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
136
WebUI/src/components/StatCard.vue
Normal file
136
WebUI/src/components/StatCard.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<el-card :class="['stat-card', type]" shadow="hover">
|
||||
<div class="stat-content">
|
||||
<el-icon v-if="icon" class="stat-icon" :size="28">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" :title="String(value)">{{ displayValue }}</div>
|
||||
<div class="stat-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 统计卡片组件 - 冷灰紫主题
|
||||
* @description 用于展示 Dashboard 上的统计数据
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** 卡片类型,影响背景色 */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'total', 'available', 'new', 'score'].includes(value)
|
||||
},
|
||||
/** 图标组件 */
|
||||
icon: {
|
||||
type: [String, Object],
|
||||
required: true
|
||||
},
|
||||
/** 数值 */
|
||||
value: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
/** 标签 */
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
const num = Number(props.value)
|
||||
if (!isNaN(num) && num > 9999) {
|
||||
return (num / 10000).toFixed(1) + 'w'
|
||||
}
|
||||
return props.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 100px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: var(--transition-hover);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 不同类型卡片的图标颜色区分 */
|
||||
.stat-card.total .stat-icon {
|
||||
color: var(--info);
|
||||
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.4));
|
||||
}
|
||||
|
||||
.stat-card.available .stat-icon {
|
||||
color: var(--success);
|
||||
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
|
||||
}
|
||||
|
||||
.stat-card.new .stat-icon {
|
||||
color: var(--warning);
|
||||
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
|
||||
}
|
||||
|
||||
.stat-card.score .stat-icon {
|
||||
color: var(--primary);
|
||||
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
WebUI/src/main.js
Normal file
20
WebUI/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
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, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
36
WebUI/src/router/index.js
Normal file
36
WebUI/src/router/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createRouter, createWebHashHistory } 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: '/plugins',
|
||||
name: 'Plugins',
|
||||
component: () => import('../views/Plugins.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
27
WebUI/src/services/pluginService.js
Normal file
27
WebUI/src/services/pluginService.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { pluginsAPI } from '../api'
|
||||
|
||||
export const pluginService = {
|
||||
async getPlugins() {
|
||||
return pluginsAPI.getPlugins()
|
||||
},
|
||||
|
||||
async togglePlugin(pluginId, enabled) {
|
||||
return pluginsAPI.togglePlugin(pluginId, enabled)
|
||||
},
|
||||
|
||||
async getPluginConfig(pluginId) {
|
||||
return pluginsAPI.getPluginConfig(pluginId)
|
||||
},
|
||||
|
||||
async updatePluginConfig(pluginId, config) {
|
||||
return pluginsAPI.updatePluginConfig(pluginId, config)
|
||||
},
|
||||
|
||||
async crawlPlugin(pluginId) {
|
||||
return pluginsAPI.crawlPlugin(pluginId)
|
||||
},
|
||||
|
||||
async crawlAll() {
|
||||
return pluginsAPI.crawlAll()
|
||||
}
|
||||
}
|
||||
27
WebUI/src/services/proxyService.js
Normal file
27
WebUI/src/services/proxyService.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { statsAPI, proxiesAPI } from '../api'
|
||||
|
||||
export const proxyService = {
|
||||
async getStats() {
|
||||
return statsAPI.getStats()
|
||||
},
|
||||
|
||||
async getProxies(params, signal) {
|
||||
return proxiesAPI.getProxies(params, signal)
|
||||
},
|
||||
|
||||
async deleteProxy(ip, port) {
|
||||
return proxiesAPI.deleteProxy(ip, port)
|
||||
},
|
||||
|
||||
async batchDelete(proxies) {
|
||||
return proxiesAPI.batchDeleteProxies(proxies)
|
||||
},
|
||||
|
||||
async cleanInvalid() {
|
||||
return proxiesAPI.cleanInvalidProxies()
|
||||
},
|
||||
|
||||
async export(format, protocol) {
|
||||
return proxiesAPI.exportProxies(format, protocol)
|
||||
}
|
||||
}
|
||||
19
WebUI/src/services/schedulerService.js
Normal file
19
WebUI/src/services/schedulerService.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { schedulerAPI } from '../api'
|
||||
|
||||
export const schedulerService = {
|
||||
async start() {
|
||||
return schedulerAPI.start()
|
||||
},
|
||||
|
||||
async stop() {
|
||||
return schedulerAPI.stop()
|
||||
},
|
||||
|
||||
async validateNow() {
|
||||
return schedulerAPI.validateNow()
|
||||
},
|
||||
|
||||
async getStatus() {
|
||||
return schedulerAPI.getStatus()
|
||||
}
|
||||
}
|
||||
11
WebUI/src/services/settingService.js
Normal file
11
WebUI/src/services/settingService.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { settingsAPI } from '../api'
|
||||
|
||||
export const settingService = {
|
||||
async getSettings() {
|
||||
return settingsAPI.getSettings()
|
||||
},
|
||||
|
||||
async saveSettings(data) {
|
||||
return settingsAPI.saveSettings(data)
|
||||
}
|
||||
}
|
||||
107
WebUI/src/stores/plugins.js
Normal file
107
WebUI/src/stores/plugins.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { pluginService } from '../services/pluginService'
|
||||
|
||||
/**
|
||||
* Plugins Store
|
||||
* 管理插件列表和状态
|
||||
*/
|
||||
export const usePluginsStore = defineStore('plugins', () => {
|
||||
// ==================== State ====================
|
||||
const plugins = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// ==================== Getters ====================
|
||||
const enabledCount = computed(() => plugins.value.filter(p => p.enabled).length)
|
||||
const totalCount = computed(() => plugins.value.length)
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 获取插件列表
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fetchPlugins() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pluginService.getPlugins()
|
||||
if (response.code === 200) {
|
||||
plugins.value = response.data.plugins || []
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取插件列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换插件启用状态
|
||||
* @param {string|number} pluginId
|
||||
* @param {boolean} enabled
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function togglePlugin(pluginId, enabled) {
|
||||
try {
|
||||
const response = await pluginService.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
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发插件爬取
|
||||
* @param {string|number} pluginId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function crawlPlugin(pluginId) {
|
||||
try {
|
||||
const response = await pluginService.crawlPlugin(pluginId)
|
||||
return response.code === 200
|
||||
} catch (error) {
|
||||
console.error('触发插件爬取失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取插件
|
||||
* @param {string|number} id
|
||||
* @returns {object|undefined}
|
||||
*/
|
||||
function getPluginById(id) {
|
||||
return plugins.value.find(p => p.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
function reset() {
|
||||
plugins.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
plugins,
|
||||
loading,
|
||||
// Getters
|
||||
enabledCount,
|
||||
totalCount,
|
||||
// Actions
|
||||
fetchPlugins,
|
||||
togglePlugin,
|
||||
crawlPlugin,
|
||||
getPluginById,
|
||||
reset
|
||||
}
|
||||
})
|
||||
179
WebUI/src/stores/proxy.js
Normal file
179
WebUI/src/stores/proxy.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { proxyService } from '../services/proxyService'
|
||||
|
||||
/**
|
||||
* 判断是否为用户取消的错误
|
||||
* @param {Error} error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAbortError(error) {
|
||||
return error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy Store
|
||||
* 管理代理列表、统计信息和相关操作
|
||||
*/
|
||||
export const useProxyStore = defineStore('proxy', () => {
|
||||
// ==================== State ====================
|
||||
const proxies = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const stats = ref({})
|
||||
|
||||
// ==================== Getters ====================
|
||||
const hasProxies = computed(() => proxies.value.length > 0)
|
||||
const isEmpty = computed(() => !loading.value && proxies.value.length === 0)
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await proxyService.getStats()
|
||||
if (response.code === 200) {
|
||||
stats.value = response.data
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理列表
|
||||
* @param {object} params - 查询参数
|
||||
* @param {AbortSignal} [signal] - 用于取消请求的信号
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fetchProxies(params, signal) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await proxyService.getProxies(params, signal)
|
||||
if (response.code === 200) {
|
||||
proxies.value = response.data.list
|
||||
total.value = response.data.total
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return false
|
||||
}
|
||||
console.error('获取代理列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个代理
|
||||
* @param {string} ip
|
||||
* @param {number|string} port
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function deleteProxy(ip, port) {
|
||||
try {
|
||||
const response = await proxyService.deleteProxy(ip, port)
|
||||
return response.code === 200
|
||||
} catch (error) {
|
||||
console.error('删除代理失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除代理
|
||||
* @param {Array<[string, number|string]>} proxyList
|
||||
* @returns {Promise<number>} 实际删除的数量
|
||||
*/
|
||||
async function batchDeleteProxies(proxyList) {
|
||||
if (!proxyList?.length) return 0
|
||||
|
||||
try {
|
||||
const response = await proxyService.batchDelete(proxyList)
|
||||
if (response.code === 200) {
|
||||
return response.data.deleted_count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除代理失败:', error)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理无效代理
|
||||
* @returns {Promise<number>} 删除的数量
|
||||
*/
|
||||
async function cleanInvalidProxies() {
|
||||
try {
|
||||
const response = await proxyService.cleanInvalid()
|
||||
if (response.code === 200) {
|
||||
return response.data.deleted_count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理无效代理失败:', error)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出代理
|
||||
* @param {string} format - 导出格式 (txt/csv/json)
|
||||
* @param {string|null} protocol - 协议过滤
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function exportProxies(format, protocol = null) {
|
||||
try {
|
||||
const response = await proxyService.export(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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
function reset() {
|
||||
proxies.value = []
|
||||
total.value = 0
|
||||
stats.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
proxies,
|
||||
total,
|
||||
loading,
|
||||
stats,
|
||||
// Getters
|
||||
hasProxies,
|
||||
isEmpty,
|
||||
// Actions
|
||||
fetchStats,
|
||||
fetchProxies,
|
||||
deleteProxy,
|
||||
batchDeleteProxies,
|
||||
cleanInvalidProxies,
|
||||
exportProxies,
|
||||
reset
|
||||
}
|
||||
})
|
||||
104
WebUI/src/style.css
Normal file
104
WebUI/src/style.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@import './styles/variables.css';
|
||||
@import './styles/utilities.css';
|
||||
@import './styles/element-plus.css';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg);
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* 滚动条 - 深色主题 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 选中文本颜色 */
|
||||
::selection {
|
||||
background: rgba(146, 124, 255, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes gradientShift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(146, 124, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(146, 124, 255, 0.5);
|
||||
}
|
||||
}
|
||||
593
WebUI/src/styles/element-plus.css
Normal file
593
WebUI/src/styles/element-plus.css
Normal file
@@ -0,0 +1,593 @@
|
||||
/* ==================== Element Plus 冷灰紫主题覆盖 ==================== */
|
||||
|
||||
/* -------------------- 输入框 -------------------- */
|
||||
.el-input__wrapper {
|
||||
background-color: var(--surface-3) !important;
|
||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.el-input__inner::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 选择器 -------------------- */
|
||||
.el-select__wrapper {
|
||||
background-color: var(--surface-3) !important;
|
||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-select__wrapper:hover,
|
||||
.el-select__wrapper.is-focused {
|
||||
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
||||
}
|
||||
|
||||
.el-select__wrapper.is-focused {
|
||||
box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important;
|
||||
}
|
||||
|
||||
.el-select__placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.el-select__caret {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.el-select__caret.is-reverse {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item:hover {
|
||||
background: var(--primary-soft) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.is-selected {
|
||||
color: var(--primary) !important;
|
||||
font-weight: 600;
|
||||
background: var(--primary-soft) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 数字输入框 -------------------- */
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-input-number__decrease:hover,
|
||||
.el-input-number__increase:hover {
|
||||
background: var(--primary-soft) !important;
|
||||
color: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-input-number__decrease.is-disabled,
|
||||
.el-input-number__increase.is-disabled {
|
||||
color: var(--el-disabled-text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.el-input-number__wrapper {
|
||||
background-color: var(--surface-3) !important;
|
||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||
}
|
||||
|
||||
.el-input-number__wrapper:hover,
|
||||
.el-input-number__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
||||
}
|
||||
|
||||
/* -------------------- 按钮 -------------------- */
|
||||
.el-button {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-button:hover {
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
background: var(--surface-3) !important;
|
||||
}
|
||||
|
||||
/* 主要按钮 - 深紫实心 */
|
||||
.el-button--primary {
|
||||
background: var(--primary-solid) !important;
|
||||
border-color: var(--primary-solid) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background: var(--primary-solid-hover) !important;
|
||||
border-color: var(--primary-solid-hover) !important;
|
||||
box-shadow: var(--shadow-primary-md) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 成功按钮 - 青绿 */
|
||||
.el-button--success {
|
||||
background: var(--success) !important;
|
||||
border-color: var(--success) !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
|
||||
.el-button--success:hover {
|
||||
background: #2DD4BF !important;
|
||||
border-color: #2DD4BF !important;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 警告按钮 - 橙黄 */
|
||||
.el-button--warning {
|
||||
background: var(--warning) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
|
||||
.el-button--warning:hover {
|
||||
background: #FBBF24 !important;
|
||||
border-color: #FBBF24 !important;
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 危险按钮 - 粉红 */
|
||||
.el-button--danger {
|
||||
background: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.el-button--danger:hover {
|
||||
background: #FCA5A5 !important;
|
||||
border-color: #FCA5A5 !important;
|
||||
box-shadow: 0 0 20px rgba(251, 113, 133, 0.3) !important;
|
||||
}
|
||||
|
||||
/* 纯文字按钮 */
|
||||
.el-button--text {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-button--text:hover {
|
||||
color: var(--primary-hover) !important;
|
||||
background: var(--primary-soft) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 卡片 -------------------- */
|
||||
.el-card {
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: none !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
border-color: var(--border-light) !important;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
background: transparent !important;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* -------------------- 表格 -------------------- */
|
||||
.el-table {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
--el-table-row-hover-bg-color: var(--surface-2);
|
||||
--el-table-current-row-bg-color: var(--primary-soft);
|
||||
--el-table-header-bg-color: var(--surface-2);
|
||||
--el-table-tr-bg-color: var(--surface);
|
||||
--el-table-expanded-cell-bg-color: var(--surface);
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
color: var(--text-primary) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-table__border-left {
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-table__border-right {
|
||||
border-right: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-table tr:hover > td {
|
||||
background: var(--surface-2) !important;
|
||||
}
|
||||
|
||||
.el-table__body tr.current-row > td.el-table__cell {
|
||||
background: var(--primary-soft) !important;
|
||||
}
|
||||
|
||||
/* 表格行选中左侧高亮条 */
|
||||
.el-table__body tr.current-row > td.el-table__cell:first-child {
|
||||
border-left: 3px solid var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 复选框 -------------------- */
|
||||
.el-checkbox__inner {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--surface-3) !important;
|
||||
}
|
||||
|
||||
.el-checkbox__inner:hover {
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-checkbox__input.is-disabled .el-checkbox__inner {
|
||||
background: var(--el-disabled-bg) !important;
|
||||
border-color: var(--el-disabled-border) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 分页 -------------------- */
|
||||
.el-pagination button {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--surface) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
.el-pagination button:hover {
|
||||
background: var(--surface-2) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-pagination button:disabled {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text-muted) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.el-pager li {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
.el-pager li:hover {
|
||||
color: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-pager li.is-active {
|
||||
background: var(--primary) !important;
|
||||
color: var(--bg) !important;
|
||||
border-color: var(--primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* -------------------- 标签 -------------------- */
|
||||
.el-tag {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-tag--primary {
|
||||
background: var(--primary-soft) !important;
|
||||
color: var(--primary) !important;
|
||||
border-color: rgba(146, 124, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--success {
|
||||
background: var(--success-soft) !important;
|
||||
color: var(--success) !important;
|
||||
border-color: rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--warning {
|
||||
background: var(--warning-soft) !important;
|
||||
color: var(--warning) !important;
|
||||
border-color: rgba(245, 158, 11, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--danger {
|
||||
background: var(--danger-soft) !important;
|
||||
color: var(--danger) !important;
|
||||
border-color: rgba(251, 113, 133, 0.3) !important;
|
||||
}
|
||||
|
||||
.el-tag--info {
|
||||
background: var(--info-soft) !important;
|
||||
color: var(--info) !important;
|
||||
border-color: rgba(56, 189, 248, 0.3) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 评分 -------------------- */
|
||||
.el-rate__icon {
|
||||
color: var(--border) !important;
|
||||
}
|
||||
|
||||
.el-rate__icon.hover {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 对话框 -------------------- */
|
||||
.el-dialog {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
background: transparent !important;
|
||||
color: var(--text-secondary) !important;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* -------------------- 下拉菜单 -------------------- */
|
||||
.el-dropdown-menu {
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:hover {
|
||||
background: var(--primary-soft) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 滚动条 -------------------- */
|
||||
.el-scrollbar__wrap::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.el-scrollbar__wrap::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.el-scrollbar__wrap::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* -------------------- 表单 -------------------- */
|
||||
.el-form-item__label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-form-item__error {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 消息提示 -------------------- */
|
||||
.el-message {
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-message--success {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--success) !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.el-message--error {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.el-message--warning {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.el-message--info {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 消息盒子 -------------------- */
|
||||
.el-message-box {
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
background: var(--surface) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
}
|
||||
|
||||
.el-message-box__header {
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.el-message-box__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-message-box__content {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.el-message-box__btns {
|
||||
border-top: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 警告提示 -------------------- */
|
||||
.el-alert {
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-alert--success {
|
||||
background-color: var(--success-soft) !important;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.el-alert--info {
|
||||
background-color: var(--primary-soft) !important;
|
||||
border: 1px solid rgba(146, 124, 255, 0.3) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-alert--warning {
|
||||
background-color: var(--warning-soft) !important;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.el-alert--error {
|
||||
background-color: var(--danger-soft) !important;
|
||||
border: 1px solid rgba(251, 113, 133, 0.3) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* -------------------- Switch 开关 -------------------- */
|
||||
.theme-switch.el-switch .el-switch__core {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.theme-switch.el-switch.is-checked .el-switch__core {
|
||||
border-color: var(--primary) !important;
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 进度条 -------------------- */
|
||||
.el-progress-bar__outer {
|
||||
background-color: var(--surface-3) !important;
|
||||
}
|
||||
|
||||
.el-progress-bar__inner {
|
||||
background: var(--gradient-primary) !important;
|
||||
}
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- 菜单 -------------------- */
|
||||
.el-menu {
|
||||
background: transparent !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
color: var(--text-secondary) !important;
|
||||
border-radius: var(--radius-md);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.el-menu-item:hover {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background: var(--primary-soft) !important;
|
||||
color: var(--primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* -------------------- Tabs -------------------- */
|
||||
.el-tabs__nav-wrap::after {
|
||||
background-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.el-tabs__item:hover {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-tabs__item.is-active {
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.el-tabs__active-bar {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* -------------------- Tooltip -------------------- */
|
||||
.el-tooltip__popper {
|
||||
background: var(--surface-2) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
434
WebUI/src/styles/utilities.css
Normal file
434
WebUI/src/styles/utilities.css
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 工具类 CSS - 冷灰紫主题
|
||||
* 提供通用的布局和样式工具类
|
||||
*/
|
||||
|
||||
/* ==================== 卡片 ==================== */
|
||||
.card-base {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: var(--transition-hover);
|
||||
}
|
||||
|
||||
.card-base:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ==================== 按钮工具类 ==================== */
|
||||
.btn-base {
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: var(--transition-base);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-base:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
/* 主要按钮 - 深紫实心 */
|
||||
.btn-primary {
|
||||
background: var(--primary-solid);
|
||||
border-color: var(--primary-solid);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-solid-hover);
|
||||
border-color: var(--primary-solid-hover);
|
||||
box-shadow: var(--shadow-primary-md);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 成功按钮 */
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #2DD4BF;
|
||||
border-color: #2DD4BF;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
transform: translateY(-1px);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* 警告按钮 */
|
||||
.btn-warning {
|
||||
background: var(--warning);
|
||||
border-color: var(--warning);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #FBBF24;
|
||||
border-color: #FBBF24;
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||
transform: translateY(-1px);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* 危险按钮 */
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #FCA5A5;
|
||||
border-color: #FCA5A5;
|
||||
box-shadow: 0 0 20px rgba(251, 113, 133, 0.3);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ==================== 布局 ==================== */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Flex 工具类 */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==================== 文字颜色 ==================== */
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ==================== 背景 ==================== */
|
||||
.bg-gradient-primary {
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.bg-gradient-accent {
|
||||
background: var(--gradient-accent);
|
||||
}
|
||||
|
||||
.bg-surface {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.bg-surface-2 {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.bg-surface-3 {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
/* ==================== 边框 ==================== */
|
||||
.border-default {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.border-light {
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.border-none {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ==================== 圆角 ==================== */
|
||||
.rounded-sm {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* ==================== 阴影 ==================== */
|
||||
.shadow-sm {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.shadow-xl {
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.shadow-none {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
box-shadow: var(--shadow-primary-md);
|
||||
}
|
||||
|
||||
/* ==================== 间距 ==================== */
|
||||
.gap-4 {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gap-12 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gap-20 {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ==================== 文本工具 ==================== */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ==================== 显示 ==================== */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ==================== 响应式隐藏 ==================== */
|
||||
@media (max-width: 768px) {
|
||||
.hidden-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.hidden-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 焦点样式 ==================== */
|
||||
.focus-primary:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-primary-sm);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.focus-accent:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-accent-sm);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ==================== 状态指示器 ==================== */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot--success {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot--warning {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.status-dot--danger {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px rgba(251, 113, 133, 0.5);
|
||||
}
|
||||
|
||||
.status-dot--info {
|
||||
background: var(--info);
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
|
||||
}
|
||||
|
||||
/* ==================== 分隔线 ==================== */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
margin: 0 16px;
|
||||
align-self: stretch;
|
||||
}
|
||||
87
WebUI/src/styles/variables.css
Normal file
87
WebUI/src/styles/variables.css
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* CSS 变量定义 - 冷灰紫主题
|
||||
* 设计理念:冷灰做信息底座,克制紫色做品牌识别和交互强调
|
||||
* 参考:Material 3 颜色角色体系
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ==================== 背景层次 (Surface Roles) ==================== */
|
||||
--bg: #0F1117; /* 最底层背景,接近黑但不是纯黑 */
|
||||
--surface: #181C25; /* 卡片、表格、侧边栏 */
|
||||
--surface-2: #1F2430; /* 悬停状态、次级面板 */
|
||||
--surface-3: #262C3A; /* 输入框、选中行背景 */
|
||||
--border: #2E3545; /* 边框,负责把结构切出来 */
|
||||
--border-light: #3A4356; /* 稍亮的边框 */
|
||||
|
||||
/* ==================== 文字颜色 ==================== */
|
||||
--text-primary: #F5F7FA; /* 主要文字,对比度充足 */
|
||||
--text-secondary: #A5AEBD; /* 次要文字 */
|
||||
--text-muted: #7C8596; /* 弱化文字、placeholder */
|
||||
|
||||
/* ==================== 品牌紫色系 (Brand Purple) ==================== */
|
||||
/* 亮紫:用于链接、选中、图标、焦点 - "发光"和识别 */
|
||||
--primary: #927CFF;
|
||||
--primary-rgb: 146, 124, 255;
|
||||
--primary-hover: #A78BFA;
|
||||
--primary-soft: #2A2442; /* 淡紫背景,用于选中态底色 */
|
||||
|
||||
/* 深紫:用于实心按钮,配白字更稳 - "承载文字" */
|
||||
--primary-solid: #6B4EFF;
|
||||
--primary-solid-hover: #5B3DF5;
|
||||
|
||||
/* ==================== 辅助色 (Accent) ==================== */
|
||||
--accent: #2DD4BF; /* 青绿色,辅助强调 */
|
||||
--accent-rgb: 45, 212, 191;
|
||||
--accent-soft: #163A39; /* 淡青背景 */
|
||||
|
||||
/* ==================== 语义状态色 (Semantic Colors) ==================== */
|
||||
/* 紫色只表示"交互和品牌";绿/橙/红只表示"系统状态" */
|
||||
--success: #22C55E;
|
||||
--success-rgb: 34, 197, 94;
|
||||
--success-soft: #1A3A28;
|
||||
|
||||
--warning: #F59E0B;
|
||||
--warning-rgb: 245, 158, 11;
|
||||
--warning-soft: #3D3118;
|
||||
|
||||
--danger: #FB7185;
|
||||
--danger-rgb: 251, 113, 133;
|
||||
--danger-soft: #3D1F26;
|
||||
|
||||
--info: #38BDF8;
|
||||
--info-rgb: 56, 189, 248;
|
||||
--info-soft: #1A3A4A;
|
||||
|
||||
/* ==================== 渐变 ==================== */
|
||||
--gradient-primary: linear-gradient(135deg, #6B4EFF 0%, #927CFF 100%);
|
||||
--gradient-accent: linear-gradient(135deg, #2DD4BF 0%, #38BDF8 100%);
|
||||
--gradient-surface: linear-gradient(180deg, #181C25 0%, #1F2430 100%);
|
||||
|
||||
/* ==================== 圆角 ==================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 14px;
|
||||
--radius-2xl: 16px;
|
||||
|
||||
/* ==================== 阴影 (克制使用,更多靠层级和边框) ==================== */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* 紫色光晕 - 用于焦点态 */
|
||||
--shadow-primary-sm: 0 0 0 2px rgba(146, 124, 255, 0.2);
|
||||
--shadow-primary-md: 0 0 20px rgba(146, 124, 255, 0.15);
|
||||
--shadow-accent-sm: 0 0 0 2px rgba(45, 212, 191, 0.2);
|
||||
|
||||
/* ==================== 过渡 ==================== */
|
||||
--transition-base: all 0.2s ease;
|
||||
--transition-hover: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ==================== Element Plus 专用覆盖 ==================== */
|
||||
--el-disabled-bg: #262C3A;
|
||||
--el-disabled-border: #3A4356;
|
||||
--el-disabled-text: #7C8596;
|
||||
--el-switch-inactive: #3A4356;
|
||||
}
|
||||
41
WebUI/src/utils/clipboard.js
Normal file
41
WebUI/src/utils/clipboard.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
* @param {string} text
|
||||
* @param {string} [successMsg]
|
||||
* @param {string} [errorMsg]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyToClipboard(text, successMsg, errorMsg = '复制失败呢~') {
|
||||
if (!text) {
|
||||
ElMessage.warning('没有可复制的内容')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(successMsg || `已复制 ${text} 到剪贴板啦~`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
ElMessage.error(errorMsg)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制代理地址
|
||||
* @param {object} proxy - { ip, port }
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyProxy(proxy) {
|
||||
if (!proxy?.ip || !proxy?.port) {
|
||||
ElMessage.warning('代理信息不完整')
|
||||
return false
|
||||
}
|
||||
|
||||
const text = `${proxy.ip}:${proxy.port}`
|
||||
return copyToClipboard(text)
|
||||
}
|
||||
|
||||
84
WebUI/src/utils/confirm.js
Normal file
84
WebUI/src/utils/confirm.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
/**
|
||||
* 确认对话框工具
|
||||
*/
|
||||
|
||||
/** 默认配置 */
|
||||
const DEFAULT_CONFIG = {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认对话框
|
||||
* @param {string} message
|
||||
* @param {string} [title]
|
||||
* @param {object} [config]
|
||||
* @returns {Promise<boolean>} 用户点击确定返回 true,取消返回 false
|
||||
*/
|
||||
export async function confirm(message, title = '提示', config = {}) {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, {
|
||||
...DEFAULT_CONFIG,
|
||||
...config
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示删除确认对话框
|
||||
* @param {string} message
|
||||
* @param {string} [itemName] - 要删除的项目名称
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function confirmDelete(message, itemName = '') {
|
||||
const fullMessage = itemName
|
||||
? `确定要删除${itemName}吗?此操作不可恢复。`
|
||||
: message
|
||||
|
||||
return confirm(fullMessage, '删除确认', {
|
||||
confirmButtonText: '删除吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示批量删除确认对话框
|
||||
* @param {number} count
|
||||
* @param {string} [itemName]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function confirmBatchDelete(count, itemName = '项') {
|
||||
return confirm(
|
||||
`确定要删除选中的 ${count} 个${itemName}吗?`,
|
||||
'批量删除确认',
|
||||
{
|
||||
confirmButtonText: '删除吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示清空确认对话框
|
||||
* @param {string} [target]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function confirmClear(target = '所有数据') {
|
||||
return confirm(
|
||||
`确定要清空${target}吗?此操作不可恢复。`,
|
||||
'清空确认',
|
||||
{
|
||||
confirmButtonText: '清空吧~',
|
||||
cancelButtonText: '再等等',
|
||||
type: 'danger'
|
||||
}
|
||||
)
|
||||
}
|
||||
108
WebUI/src/utils/format.js
Normal file
108
WebUI/src/utils/format.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
* @param {string|Date|number} dateTimeStr
|
||||
* @param {string} [fallback] - 无效日期时的回退文本
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDateTime(dateTimeStr, fallback = '-') {
|
||||
if (!dateTimeStr) return fallback
|
||||
|
||||
const date = new Date(dateTimeStr)
|
||||
if (isNaN(date.getTime())) return fallback
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hours = pad(date.getHours())
|
||||
const minutes = pad(date.getMinutes())
|
||||
const seconds = pad(date.getSeconds())
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(简化版)
|
||||
* @param {string|Date|number} timeStr
|
||||
* @param {string} [fallback]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTime(timeStr, fallback = '-') {
|
||||
if (!timeStr) return fallback
|
||||
|
||||
const date = new Date(timeStr)
|
||||
if (isNaN(date.getTime())) return fallback
|
||||
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(添加千分位)
|
||||
* @param {number} num
|
||||
* @param {number} [decimals] - 小数位数
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatNumber(num, decimals = 0) {
|
||||
if (typeof num !== 'number' || isNaN(num)) return '-'
|
||||
return num.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
* @param {number} value
|
||||
* @param {number} [total]
|
||||
* @param {number} [decimals]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatPercent(value, total, decimals = 1) {
|
||||
if (total !== undefined) {
|
||||
if (!total) return '0%'
|
||||
value = (value / total) * 100
|
||||
}
|
||||
return `${value.toFixed(decimals)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes
|
||||
* @param {number} [decimals]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时长(秒转可读文本)
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '-'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分${secs}秒`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分${secs}秒`
|
||||
} else {
|
||||
return `${secs}秒`
|
||||
}
|
||||
}
|
||||
36
WebUI/src/utils/message.js
Normal file
36
WebUI/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)
|
||||
}
|
||||
249
WebUI/src/views/Dashboard.vue
Normal file
249
WebUI/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<PageHeader title="代理池管理系统" :icon="MagicStick" />
|
||||
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||
<StatCard
|
||||
type="total"
|
||||
:icon="DataLine"
|
||||
:value="stats.total || 0"
|
||||
label="总代理数"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||
<StatCard
|
||||
type="available"
|
||||
:icon="CircleCheck"
|
||||
:value="stats.available || 0"
|
||||
label="可用数量"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||
<StatCard
|
||||
type="new"
|
||||
:icon="Timer"
|
||||
:value="stats.today_new || 0"
|
||||
label="今日新增"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||
<StatCard
|
||||
type="score"
|
||||
:icon="StarFilled"
|
||||
:value="avgScore"
|
||||
label="平均分数"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<ProtocolChart :data="stats" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="8">
|
||||
<QuickActions
|
||||
@export="handleExport"
|
||||
@clean="handleClean"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 系统状态 -->
|
||||
<el-row :gutter="20" class="status-row">
|
||||
<el-col :xs="24">
|
||||
<el-card class="status-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
系统状态
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span class="status-label">验证调度器</span>
|
||||
<el-tag :type="stats.scheduler_running ? 'success' : 'info'" size="large">
|
||||
{{ stats.scheduler_running ? '运行中' : '已停止' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">HTTP 代理</span>
|
||||
<span class="status-value">{{ stats.http_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">HTTPS 代理</span>
|
||||
<span class="status-value">{{ stats.https_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">SOCKS 代理</span>
|
||||
<span class="status-value">{{ (stats.socks4_count || 0) + (stats.socks5_count || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
MagicStick,
|
||||
DataLine,
|
||||
CircleCheck,
|
||||
Timer,
|
||||
StarFilled,
|
||||
InfoFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import { formatNumber } from '../utils/format'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ProtocolChart from '../components/ProtocolChart.vue'
|
||||
import QuickActions from '../components/QuickActions.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
// ==================== Store ====================
|
||||
const proxyStore = useProxyStore()
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const stats = computed(() => proxyStore.stats)
|
||||
const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1))
|
||||
|
||||
// ==================== 定时刷新 ====================
|
||||
const REFRESH_INTERVAL = 5000
|
||||
let refreshTimer = null
|
||||
let isPageVisible = true
|
||||
|
||||
function handleVisibilityChange() {
|
||||
isPageVisible = !document.hidden
|
||||
if (isPageVisible) {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await proxyStore.fetchStats()
|
||||
}
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
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 {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
await refreshData()
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
refreshTimer = setInterval(() => {
|
||||
if (isPageVisible) {
|
||||
refreshData()
|
||||
}
|
||||
}, REFRESH_INTERVAL)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row .el-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-row .el-col:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
548
WebUI/src/views/Plugins.vue
Normal file
548
WebUI/src/views/Plugins.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<PageHeader title="插件管理" :icon="Connection" />
|
||||
|
||||
<el-card class="plugins-card" shadow="hover" v-loading="pluginsStore.loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><Box /></el-icon>
|
||||
插件列表
|
||||
</span>
|
||||
<el-tag v-if="pluginsStore.totalCount > 0" size="small" type="info" class="count-tag">
|
||||
共 {{ pluginsStore.totalCount }} 个
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="success" @click="handleCrawlAll" size="large" :loading="crawlingAll">
|
||||
<el-icon class="btn-icon"><Promotion /></el-icon>
|
||||
全部爬取
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleRefresh" size="large">
|
||||
<el-icon class="btn-icon"><Refresh /></el-icon>
|
||||
刷新列表
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="pluginsStore.plugins">
|
||||
<el-table-column prop="name" label="插件名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="plugin-name">
|
||||
<el-icon class="plugin-icon"><Connection /></el-icon>
|
||||
<span class="plugin-name-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="plugin-description">{{ row.description || '暂无描述' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
@change="(val) => handleToggle(row.id, val)"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="统计" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="plugin-stats">
|
||||
<div class="stat-item">
|
||||
<el-icon class="stat-icon success"><CircleCheck /></el-icon>
|
||||
<span class="stat-value success">{{ row.success_count || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<el-icon class="stat-icon failed"><CircleClose /></el-icon>
|
||||
<span class="stat-value failed">{{ row.failure_count || 0 }}</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="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="plugin-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleOpenConfig(row)"
|
||||
>
|
||||
<el-icon class="btn-icon"><Setting /></el-icon>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleCrawl(row.id)"
|
||||
:loading="crawlingPlugins.has(row.id)"
|
||||
:disabled="!row.enabled"
|
||||
>
|
||||
<el-icon class="btn-icon"><Promotion /></el-icon>
|
||||
爬取
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="crawlResults[row.id]" class="plugin-crawl-result">
|
||||
<div class="result-mini" :class="crawlResults[row.id].type">
|
||||
<el-icon v-if="crawlResults[row.id].type === 'success'" class="result-icon success"><CircleCheck /></el-icon>
|
||||
<el-icon v-else class="result-icon failed"><CircleClose /></el-icon>
|
||||
<span class="result-text">{{ crawlResults[row.id].message }}</span>
|
||||
<span v-if="crawlResults[row.id].data?.valid_count !== undefined" class="result-count valid">
|
||||
有效 {{ crawlResults[row.id].data.valid_count }}
|
||||
</span>
|
||||
<span v-if="crawlResults[row.id].data?.invalid_count !== undefined" class="result-count invalid">
|
||||
无效 {{ crawlResults[row.id].data.invalid_count }}
|
||||
</span>
|
||||
<el-icon class="result-close" @click="clearCrawlResult(row.id)"><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty
|
||||
v-if="pluginsStore.isEmpty"
|
||||
description="暂无插件"
|
||||
:image-size="120"
|
||||
/>
|
||||
|
||||
<!-- 批量爬取结果提示 -->
|
||||
<el-alert
|
||||
v-if="allCrawlResult"
|
||||
:title="allCrawlResult.message"
|
||||
:type="allCrawlResult.type"
|
||||
closable
|
||||
class="crawl-result"
|
||||
@close="allCrawlResult = null"
|
||||
>
|
||||
<template v-if="allCrawlResult.data">
|
||||
<div class="crawl-stats">
|
||||
<span v-if="allCrawlResult.data.total_crawled !== undefined">
|
||||
爬取: {{ allCrawlResult.data.total_crawled }}
|
||||
</span>
|
||||
<span v-if="allCrawlResult.data.proxy_count !== undefined">
|
||||
爬取: {{ allCrawlResult.data.proxy_count }}
|
||||
</span>
|
||||
<span v-if="allCrawlResult.data.valid_count !== undefined" class="valid-count">
|
||||
有效: {{ allCrawlResult.data.valid_count }}
|
||||
</span>
|
||||
<span v-if="allCrawlResult.data.invalid_count !== undefined" class="invalid-count">
|
||||
无效: {{ allCrawlResult.data.invalid_count }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
|
||||
<!-- 配置编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="configDialogVisible"
|
||||
title="插件配置"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-if="currentPlugin">
|
||||
<div class="config-plugin-name">{{ currentPlugin.name }}</div>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item
|
||||
v-for="(value, key) in configForm"
|
||||
:key="key"
|
||||
:label="String(key)"
|
||||
>
|
||||
<el-input-number
|
||||
v-if="typeof value === 'number'"
|
||||
v-model="configForm[key]"
|
||||
:min="0"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="typeof value === 'boolean'"
|
||||
v-model="configForm[key]"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="configForm[key]"
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveConfig" :loading="savingConfig">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Connection,
|
||||
Refresh,
|
||||
Promotion,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
Box,
|
||||
Setting,
|
||||
Close
|
||||
} from '@element-plus/icons-vue'
|
||||
import { usePluginsStore } from '../stores/plugins'
|
||||
import { pluginService } from '../services/pluginService'
|
||||
import { formatTime } from '../utils/format'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
const pluginsStore = usePluginsStore()
|
||||
const crawlingPlugins = ref(new Set())
|
||||
const crawlingAll = ref(false)
|
||||
const crawlResults = ref({})
|
||||
const allCrawlResult = ref(null)
|
||||
|
||||
// 配置对话框
|
||||
const configDialogVisible = ref(false)
|
||||
const currentPlugin = ref(null)
|
||||
const configForm = reactive({})
|
||||
const savingConfig = ref(false)
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
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 handleOpenConfig(row) {
|
||||
currentPlugin.value = row
|
||||
const response = await pluginService.getPluginConfig(row.id)
|
||||
if (response.code === 200) {
|
||||
Object.keys(configForm).forEach(key => delete configForm[key])
|
||||
Object.assign(configForm, response.data.config || {})
|
||||
configDialogVisible.value = true
|
||||
} else {
|
||||
ElMessage.error('获取插件配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
if (!currentPlugin.value) return
|
||||
savingConfig.value = true
|
||||
try {
|
||||
const response = await pluginService.updatePluginConfig(currentPlugin.value.id, { ...configForm })
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('配置保存成功')
|
||||
configDialogVisible.value = false
|
||||
} else {
|
||||
ElMessage.error(response.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('保存配置出错')
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCrawl(pluginId) {
|
||||
try {
|
||||
crawlingPlugins.value.add(pluginId)
|
||||
|
||||
const response = await pluginService.crawlPlugin(pluginId)
|
||||
|
||||
if (response.code === 200) {
|
||||
crawlResults.value[pluginId] = {
|
||||
type: 'success',
|
||||
message: response.message,
|
||||
data: response.data
|
||||
}
|
||||
} else {
|
||||
crawlResults.value[pluginId] = {
|
||||
type: 'error',
|
||||
message: response.message || '爬取失败'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
crawlResults.value[pluginId] = {
|
||||
type: 'error',
|
||||
message: '爬取过程出错'
|
||||
}
|
||||
} finally {
|
||||
crawlingPlugins.value.delete(pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
function clearCrawlResult(pluginId) {
|
||||
delete crawlResults.value[pluginId]
|
||||
}
|
||||
|
||||
async function handleCrawlAll() {
|
||||
try {
|
||||
const enabledPlugins = pluginsStore.plugins.filter(p => p.enabled)
|
||||
if (enabledPlugins.length === 0) {
|
||||
ElMessage.warning('没有启用的插件')
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`确定要运行所有 ${enabledPlugins.length} 个启用的插件吗?这将爬取并验证所有代理。`,
|
||||
'批量爬取确认',
|
||||
{
|
||||
confirmButtonText: '开始爬取',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}
|
||||
)
|
||||
|
||||
crawlingAll.value = true
|
||||
allCrawlResult.value = null
|
||||
|
||||
const response = await pluginService.crawlAll()
|
||||
|
||||
if (response.code === 200) {
|
||||
allCrawlResult.value = {
|
||||
type: 'success',
|
||||
message: response.message,
|
||||
data: response.data
|
||||
}
|
||||
ElMessage.success('批量爬取完成')
|
||||
} else {
|
||||
allCrawlResult.value = {
|
||||
type: 'error',
|
||||
message: response.message || '批量爬取失败'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('批量爬取失败:', error)
|
||||
allCrawlResult.value = {
|
||||
type: 'error',
|
||||
message: '批量爬取过程出错'
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
crawlingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
await pluginsStore.fetchPlugins()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plugins-card {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.plugins-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.count-tag {
|
||||
background: var(--surface-2) !important;
|
||||
border-color: var(--border) !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-icon {
|
||||
color: var(--primary);
|
||||
filter: drop-shadow(0 0 6px rgba(146, 124, 255, 0.3));
|
||||
}
|
||||
|
||||
.plugin-name-text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-icon.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-icon.failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.last-run {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crawl-result {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.crawl-stats {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.valid-count {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.invalid-count {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.config-plugin-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-crawl-result {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-mini.success {
|
||||
background: rgba(103, 194, 58, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-mini.error {
|
||||
background: rgba(245, 108, 108, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-weight: 600;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.result-count.valid {
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-count.invalid {
|
||||
background: rgba(245, 108, 108, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.result-close {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.result-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
329
WebUI/src/views/ProxyList.vue
Normal file
329
WebUI/src/views/ProxyList.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<PageHeader title="代理列表" :icon="Document" />
|
||||
|
||||
<el-card class="filter-card" shadow="hover">
|
||||
<el-form :inline="true" :model="filterForm" class="form-row">
|
||||
<el-form-item label="协议类型">
|
||||
<el-select
|
||||
v-model="filterForm.protocol"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="HTTP" value="http" />
|
||||
<el-option label="HTTPS" value="https" />
|
||||
<el-option label="SOCKS4" value="socks4" />
|
||||
<el-option label="SOCKS5" value="socks5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低分数">
|
||||
<el-input-number
|
||||
v-model="filterForm.minScore"
|
||||
:min="0"
|
||||
:max="10"
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序方式">
|
||||
<el-select v-model="filterForm.sortBy" style="width: 140px" @change="handleSearch">
|
||||
<el-option label="更新时间" value="last_check" />
|
||||
<el-option label="分数" value="score" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="table-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><List /></el-icon>
|
||||
代理详情
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleBatchDelete"
|
||||
:disabled="selectedProxies.length === 0"
|
||||
>
|
||||
<el-icon class="btn-icon"><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="handleExport">
|
||||
<el-button type="success">
|
||||
<el-icon class="btn-icon"><Download /></el-icon>
|
||||
导出
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<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>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="proxyStore.proxies"
|
||||
style="width: 100%"
|
||||
v-loading="proxyStore.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
:row-style="{ cursor: 'pointer' }"
|
||||
empty-text="暂无数据"
|
||||
>
|
||||
<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="{ row }">
|
||||
<el-tag :type="getProtocolType(row.protocol)" effect="light" size="small">
|
||||
{{ row.protocol.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="score" label="分数" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="score-value" :class="{ 'score-high': row.score >= 8, 'score-medium': row.score >= 5 && row.score < 8, 'score-low': row.score < 5 }">
|
||||
{{ row.score || 0 }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_check" label="最后检查时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.last_check) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="handleCopy(row)"
|
||||
>
|
||||
<el-icon class="btn-icon"><CopyDocument /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click.stop="handleDelete(row)"
|
||||
>
|
||||
<el-icon class="btn-icon"><Delete /></el-icon>
|
||||
删除
|
||||
</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="PAGE_SIZE_OPTIONS"
|
||||
:total="proxyStore.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Document, ArrowDown, List, Delete, Download, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { useProxyStore } from '../stores/proxy'
|
||||
import { formatDateTime } from '../utils/format'
|
||||
import { confirmDelete, confirmBatchDelete } from '../utils/confirm'
|
||||
import { copyProxy } from '../utils/clipboard'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
/** 分页选项 */
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
/** 默认分页大小 */
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
const proxyStore = useProxyStore()
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const selectedProxies = ref([])
|
||||
let abortController = null
|
||||
|
||||
const filterForm = reactive({
|
||||
protocol: '',
|
||||
minScore: 0,
|
||||
sortBy: 'last_check',
|
||||
sortOrder: 'DESC'
|
||||
})
|
||||
|
||||
// ==================== 协议类型映射 ====================
|
||||
const PROTOCOL_TYPE_MAP = {
|
||||
http: 'info',
|
||||
https: 'success',
|
||||
socks4: 'warning',
|
||||
socks5: 'primary'
|
||||
}
|
||||
|
||||
function getProtocolType(protocol) {
|
||||
return PROTOCOL_TYPE_MAP[protocol] || 'info'
|
||||
}
|
||||
|
||||
// ==================== 数据获取 ====================
|
||||
async function fetchProxies() {
|
||||
// 取消之前的请求
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
abortController = new AbortController()
|
||||
|
||||
await proxyStore.fetchProxies({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
protocol: filterForm.protocol || null,
|
||||
min_score: filterForm.minScore,
|
||||
sort_by: filterForm.sortBy,
|
||||
sort_order: filterForm.sortOrder
|
||||
}, abortController.signal)
|
||||
|
||||
abortController = null
|
||||
}
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
function handleSelectionChange(selection) {
|
||||
selectedProxies.value = selection.map(item => [item.ip, item.port])
|
||||
}
|
||||
|
||||
async function handleCopy(proxy) {
|
||||
await copyProxy(proxy)
|
||||
}
|
||||
|
||||
async function handleDelete(proxy) {
|
||||
const confirmed = await confirmDelete(`代理 ${proxy.ip}:${proxy.port}`)
|
||||
if (!confirmed) return
|
||||
|
||||
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
|
||||
if (success) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchProxies()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const count = selectedProxies.value.length
|
||||
if (!count) return
|
||||
|
||||
const confirmed = await confirmBatchDelete(count, '代理')
|
||||
if (!confirmed) return
|
||||
|
||||
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
|
||||
if (deletedCount > 0) {
|
||||
ElMessage.success(`已删除 ${deletedCount} 个代理`)
|
||||
selectedProxies.value = []
|
||||
fetchProxies()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format) {
|
||||
const success = await proxyStore.exportProxies(format, filterForm.protocol || null)
|
||||
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()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-card:hover,
|
||||
.filter-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.score-high {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.score-medium {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.score-low {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
470
WebUI/src/views/Settings.vue
Normal file
470
WebUI/src/views/Settings.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<PageHeader title="系统设置" :icon="Setting" />
|
||||
|
||||
<!-- 验证调度器控制 -->
|
||||
<el-card class="settings-card scheduler-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><Timer /></el-icon>
|
||||
验证调度器
|
||||
</span>
|
||||
<div class="scheduler-status">
|
||||
<span class="status-dot" :class="{ active: schedulerRunning }"></span>
|
||||
<span class="status-text">{{ schedulerRunning ? '运行中' : '已停止' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scheduler-actions">
|
||||
<el-button
|
||||
type="success"
|
||||
@click="handleStartScheduler"
|
||||
:disabled="schedulerRunning"
|
||||
:loading="schedulerLoading"
|
||||
>
|
||||
<el-icon class="btn-icon"><VideoPlay /></el-icon>
|
||||
启动自动验证
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleStopScheduler"
|
||||
:disabled="!schedulerRunning"
|
||||
:loading="schedulerLoading"
|
||||
>
|
||||
<el-icon class="btn-icon"><VideoPause /></el-icon>
|
||||
停止自动验证
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleValidateNow"
|
||||
:loading="validating"
|
||||
>
|
||||
<el-icon class="btn-icon"><Refresh /></el-icon>
|
||||
立即验证全部
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="scheduler-info">
|
||||
<el-alert
|
||||
:title="schedulerInfo"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<el-card class="settings-card" shadow="hover" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<el-icon class="header-icon"><Tools /></el-icon>
|
||||
基础配置
|
||||
</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSave"
|
||||
size="large"
|
||||
:loading="saving"
|
||||
>
|
||||
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
:model="settings"
|
||||
label-width="180px"
|
||||
class="settings-form"
|
||||
:rules="formRules"
|
||||
ref="formRef"
|
||||
>
|
||||
<el-divider content-position="left">爬虫配置</el-divider>
|
||||
|
||||
<el-form-item label="爬取超时" prop="crawl_timeout">
|
||||
<el-input-number
|
||||
v-model="settings.crawl_timeout"
|
||||
:min="5"
|
||||
:max="120"
|
||||
:step="5"
|
||||
class="setting-input"
|
||||
/>
|
||||
<span class="setting-suffix">秒</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大重试次数" prop="max_retries">
|
||||
<el-input-number
|
||||
v-model="settings.max_retries"
|
||||
:min="0"
|
||||
:max="10"
|
||||
class="setting-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">验证配置</el-divider>
|
||||
|
||||
<el-form-item label="验证超时" prop="validation_timeout">
|
||||
<el-input-number
|
||||
v-model="settings.validation_timeout"
|
||||
:min="3"
|
||||
:max="60"
|
||||
:step="1"
|
||||
class="setting-input"
|
||||
/>
|
||||
<span class="setting-suffix">秒</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证并发数" prop="default_concurrency">
|
||||
<el-input-number
|
||||
v-model="settings.default_concurrency"
|
||||
:min="10"
|
||||
:max="200"
|
||||
:step="10"
|
||||
class="setting-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自动验证间隔" prop="validate_interval_minutes">
|
||||
<el-input-number
|
||||
v-model="settings.validate_interval_minutes"
|
||||
:min="5"
|
||||
:max="1440"
|
||||
:step="5"
|
||||
class="setting-input"
|
||||
/>
|
||||
<span class="setting-suffix">分钟</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用自动验证" prop="auto_validate">
|
||||
<el-switch
|
||||
v-model="settings.auto_validate"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">代理评分配置</el-divider>
|
||||
|
||||
<el-form-item label="最低代理分数" prop="min_proxy_score">
|
||||
<el-input-number
|
||||
v-model="settings.min_proxy_score"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="setting-input"
|
||||
/>
|
||||
<span class="setting-hint">分数低于此值的代理将被隐藏</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="代理过期时间" prop="proxy_expiry_days">
|
||||
<el-input-number
|
||||
v-model="settings.proxy_expiry_days"
|
||||
:min="1"
|
||||
:max="30"
|
||||
:step="1"
|
||||
class="setting-input"
|
||||
/>
|
||||
<span class="setting-suffix">天</span>
|
||||
<span class="setting-hint">超过此时间未验证的代理将被清理</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Setting,
|
||||
DocumentChecked,
|
||||
Tools,
|
||||
Timer,
|
||||
VideoPlay,
|
||||
VideoPause,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import { settingService } from '../services/settingService'
|
||||
import { schedulerService } from '../services/schedulerService'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const schedulerRunning = ref(false)
|
||||
const schedulerLoading = ref(false)
|
||||
const validating = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
crawl_timeout: 30,
|
||||
validation_timeout: 10,
|
||||
max_retries: 3,
|
||||
default_concurrency: 50,
|
||||
min_proxy_score: 0,
|
||||
proxy_expiry_days: 7,
|
||||
auto_validate: true,
|
||||
validate_interval_minutes: 30
|
||||
})
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const schedulerInfo = computed(() => {
|
||||
if (schedulerRunning.value) {
|
||||
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟自动验证一次所有代理`
|
||||
} else {
|
||||
return '验证调度器已停止,代理不会自动验证,建议定期手动验证或开启自动验证'
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 表单验证规则 ====================
|
||||
const formRules = {
|
||||
crawl_timeout: [{ type: 'number', min: 5, max: 120, message: '范围 5-120 秒', trigger: 'blur' }],
|
||||
validation_timeout: [{ type: 'number', min: 3, max: 60, message: '范围 3-60 秒', trigger: 'blur' }],
|
||||
max_retries: [{ type: 'number', min: 0, max: 10, message: '范围 0-10', trigger: 'blur' }],
|
||||
default_concurrency: [{ type: 'number', min: 10, max: 200, message: '范围 10-200', trigger: 'blur' }],
|
||||
validate_interval_minutes: [{ type: 'number', min: 5, max: 1440, message: '范围 5-1440 分钟', trigger: 'blur' }],
|
||||
min_proxy_score: [{ type: 'number', min: 0, max: 100, message: '范围 0-100', trigger: 'blur' }],
|
||||
proxy_expiry_days: [{ type: 'number', min: 1, max: 30, message: '范围 1-30 天', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// ==================== 数据获取 ====================
|
||||
async function fetchSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingService.getSettings()
|
||||
if (response.code === 200) {
|
||||
Object.assign(settings, response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设置失败:', error)
|
||||
ElMessage.error('获取设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSchedulerStatus() {
|
||||
try {
|
||||
const response = await schedulerService.getStatus()
|
||||
if (response.code === 200) {
|
||||
schedulerRunning.value = response.data.running
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取调度器状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 调度器控制 ====================
|
||||
async function handleStartScheduler() {
|
||||
schedulerLoading.value = true
|
||||
try {
|
||||
const response = await schedulerService.start()
|
||||
if (response.code === 200) {
|
||||
schedulerRunning.value = true
|
||||
ElMessage.success('自动验证已启动')
|
||||
} else {
|
||||
ElMessage.error('启动失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启动调度器失败:', error)
|
||||
ElMessage.error('启动失败')
|
||||
} finally {
|
||||
schedulerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopScheduler() {
|
||||
schedulerLoading.value = true
|
||||
try {
|
||||
const response = await schedulerService.stop()
|
||||
if (response.code === 200) {
|
||||
schedulerRunning.value = false
|
||||
ElMessage.success('自动验证已停止')
|
||||
} else {
|
||||
ElMessage.error('停止失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止调度器失败:', error)
|
||||
ElMessage.error('停止失败')
|
||||
} finally {
|
||||
schedulerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidateNow() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要立即验证所有代理吗?这可能需要一些时间。',
|
||||
'确认验证',
|
||||
{
|
||||
confirmButtonText: '开始验证',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}
|
||||
)
|
||||
|
||||
validating.value = true
|
||||
const response = await schedulerService.validateNow()
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('全量验证已启动,请在日志中查看进度')
|
||||
} else {
|
||||
ElMessage.error('启动验证失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('启动验证失败:', error)
|
||||
ElMessage.error('启动验证失败')
|
||||
}
|
||||
} finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 保存 ====================
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await settingService.saveSettings(settings)
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('配置保存成功')
|
||||
// 刷新调度器状态
|
||||
await fetchSchedulerStatus()
|
||||
} else {
|
||||
ElMessage.error('配置保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error)
|
||||
ElMessage.error('配置保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
fetchSchedulerStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-card {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.settings-card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.scheduler-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scheduler-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: #67c23a;
|
||||
box-shadow: 0 0 8px #67c23a;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scheduler-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scheduler-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
padding: 16px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.setting-suffix {
|
||||
margin-left: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-divider__text) {
|
||||
background: var(--surface);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-alert) {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
34
WebUI/vite.config.js
Normal file
34
WebUI/vite.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 9948,
|
||||
// 支持 Vue Router 的 history 模式
|
||||
historyApiFallback: true
|
||||
},
|
||||
preview: {
|
||||
port: 9948,
|
||||
historyApiFallback: true
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/echarts')) {
|
||||
return 'echarts'
|
||||
}
|
||||
if (id.includes('node_modules/element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
chunkSizeWarningLimit: 600
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user