重构代理池系统:简化架构并增强核心功能

后端变更:
- 移除 tasks_manager.py 和 core/auth.py,简化架构
- 新增 core/scheduler.py 验证调度器,替代原有任务管理
- 大幅优化 api_server.py:统一错误处理、增强参数验证、支持调度器控制
- validator.py 增强 SOCKS4/SOCKS5 代理验证支持
- config.py 清理废弃配置(WebSocket、API Key、认证开关)
- SQLite 数据库操作性能优化

前端变更:
- 移除任务管理页面 (CrawlerTasks) 和 WebSocket 相关代码
- 路由简化为 4 个核心页面:总览、代理列表、插件管理、设置
- 提取前端工具函数(clipboard、confirm、format)和 API 类型定义
- 优化 CSS 架构:完善 variables、utilities、element-plus 样式
- Dashboard、Plugins、ProxyList、Settings 页面 UI/UX 优化
- App.vue 响应式侧边栏和页面过渡动画优化

其他:
- 移除 PowerShell 启动脚本,简化 Windows 批处理脚本
- 新增 README_SOCKS.md SOCKS 代理支持文档
- .env.example 和 .gitignore 更新
This commit is contained in:
祀梦
2026-04-02 11:23:23 +08:00
parent b5932a95b2
commit a79f78b338
47 changed files with 3748 additions and 3190 deletions

View File

@@ -1,5 +0,0 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>代理池管理系统</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,175 +1,256 @@
<script setup>
import { RouterView, useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const activeMenu = computed(() => route.path)
</script>
<template>
<div class="app-container">
<el-menu
:default-active="activeMenu"
class="side-menu"
router
>
<aside class="sidebar">
<div class="logo-section">
<div class="logo">🌸</div>
<div class="logo-text">代理池</div>
<el-icon class="logo" :size="40"><Grid /></el-icon>
<h1 class="logo-text">代理池</h1>
</div>
<el-menu-item index="/dashboard">
<template #title>
<span class="menu-icon">🏠</span>
<span>总览</span>
</template>
</el-menu-item>
<el-menu-item index="/proxies">
<template #title>
<span class="menu-icon">📋</span>
<span>代理列表</span>
</template>
</el-menu-item>
<el-menu-item index="/crawler">
<template #title>
<span class="menu-icon">🎀</span>
<span>任务管理</span>
</template>
</el-menu-item>
<el-menu-item index="/plugins">
<template #title>
<span class="menu-icon">🔌</span>
<span>插件管理</span>
</template>
</el-menu-item>
<el-menu-item index="/settings">
<template #title>
<span class="menu-icon"></span>
<span>设置</span>
</template>
</el-menu-item>
</el-menu>
<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>
<div class="main-content">
<RouterView />
</div>
<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);
}
.side-menu {
width: 240px;
/* 侧边栏 - 冷灰紫风格 */
.sidebar {
width: 220px;
height: 100%;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
box-shadow: 4px 0 20px rgba(255, 107, 157, 0.1);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
z-index: 10;
z-index: 100;
display: flex;
flex-direction: column;
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 35px 0;
padding: 24px 20px;
border-bottom: 1px solid var(--border);
position: relative;
}
.logo-section::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--primary), transparent);
animation: shimmer 3s infinite;
}
.logo {
font-size: 52px;
margin-bottom: 10px;
animation: float 3s ease-in-out infinite;
color: var(--primary);
margin-right: 12px;
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
}
.logo-text {
font-size: 22px;
font-weight: 700;
color: var(--primary);
text-shadow: 0 0 20px rgba(255, 107, 157, 0.3);
letter-spacing: 2px;
}
.menu-icon {
font-size: 20px;
margin-right: 12px;
}
:deep(.el-menu) {
border-right: none;
background-color: transparent;
padding: 20px 0;
}
:deep(.el-menu-item) {
border-radius: 12px;
margin: 8px 12px;
transition: var(--transition-hover);
color: var(--text-secondary);
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;
}
:deep(.el-menu-item::before) {
/* 悬停状态 */
.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: 0;
height: 100%;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--primary);
transform: scaleY(0);
transition: transform 0.3s ease;
border-radius: 0 2px 2px 0;
}
:deep(.el-menu-item:hover) {
background: rgba(0, 212, 255, 0.1) !important;
color: var(--primary);
transform: translateX(8px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
.menu-icon {
margin-right: 12px;
flex-shrink: 0;
}
:deep(.el-menu-item:hover::before) {
transform: scaleY(1);
}
:deep(.el-menu-item.is-active) {
background: var(--gradient-cyan) !important;
color: var(--bg-page) !important;
font-weight: 700;
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.4);
}
:deep(.el-menu-item.is-active::before) {
transform: scaleY(1);
.menu-label {
white-space: nowrap;
font-size: 14px;
}
/* 主内容区 */
.main-content {
flex: 1;
overflow-y: auto;
background: var(--bg-page);
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>

View File

@@ -1,73 +1,160 @@
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 || 'http://localhost:8923',
timeout: 30000
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
timeout: REQUEST_TIMEOUT
})
api.interceptors.request.use(
config => {
const apiKey = localStorage.getItem('api_key')
if (apiKey) {
config.headers['X-API-Key'] = apiKey
}
return config
},
error => {
return Promise.reject(error)
/**
* 从 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,
error => {
(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)
}
)
/**
* 清理请求参数,移除 null/undefined/空字符串
* @param {object} params
* @returns {object}
*/
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
}
/**
* 生成请求配置,支持 AbortSignal
* @param {AbortSignal} [signal]
* @returns {object}
*/
function createRequestConfig(signal) {
return signal ? { signal } : {}
}
// ==================== API 模块 ====================
export const statsAPI = {
/** @returns {Promise<import('./types').ApiResponse<import('./types').StatsData>>} */
getStats: () => api.get('/api/stats')
}
export const proxiesAPI = {
getProxies: (params) => {
const cleanedParams = {}
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
cleanedParams[key] = params[key]
}
})
return api.post('/api/proxies', cleanedParams)
},
getRandomProxy: () => api.get('/api/proxies/random'),
getProxyDetail: (ip, port) => api.get(`/api/proxies/${ip}/${port}`),
/**
* @param {object} params
* @param {AbortSignal} [signal]
* @returns {Promise<import('./types').ApiResponse<import('./types').ProxyListData>>}
*/
getProxies: (params, signal) =>
api.post('/api/proxies', cleanParams(params), createRequestConfig(signal)),
/**
* @param {string} ip
* @param {number|string} port
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`),
/**
* @param {Array<[string, number|string]>} proxies
* @returns {Promise<import('./types').ApiResponse<{deleted_count: number}>>}
*/
batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }),
/** @returns {Promise<import('./types').ApiResponse<{deleted_count: number}>>} */
cleanInvalidProxies: () => api.delete('/api/proxies/clean-invalid'),
/**
* @param {string} format
* @param {string|null} protocol
* @returns {Promise<Blob>}
*/
exportProxies: (format, protocol) => api.get(`/api/proxies/export/${format}`, {
params: protocol ? { protocol } : {},
responseType: 'blob'
})
}
export const crawlerAPI = {
start: (numValidators = 50) => api.post('/api/crawler/start', { num_validators: numValidators }),
stop: () => api.post('/api/crawler/stop'),
getStatus: () => api.get('/api/crawler/status')
export const pluginsAPI = {
/** @returns {Promise<import('./types').ApiResponse<{plugins: import('./types').Plugin[] }>>} */
getPlugins: () => api.get('/api/plugins'),
/**
* @param {string|number} pluginId
* @param {boolean} enabled
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
/**
* @param {string|number} pluginId
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`),
/** @returns {Promise<import('./types').ApiResponse<any>>} */
crawlAll: () => api.post('/api/plugins/crawl-all')
}
export const schedulerAPI = {
setScheduler: (enabled, intervalMinutes = 60) => api.post('/api/scheduler', { enabled, interval_minutes: intervalMinutes }),
getStatus: () => api.get('/api/scheduler')
/** @returns {Promise<import('./types').ApiResponse<{running: boolean}>>} */
start: () => api.post('/api/scheduler/start'),
/** @returns {Promise<import('./types').ApiResponse<{running: boolean}>>} */
stop: () => api.post('/api/scheduler/stop'),
/** @returns {Promise<import('./types').ApiResponse<{started: boolean}>>} */
validateNow: () => api.post('/api/scheduler/validate-now'),
/** @returns {Promise<import('./types').ApiResponse<{running: boolean, interval_minutes: number}>>} */
getStatus: () => api.get('/api/scheduler/status')
}
export const pluginsAPI = {
getPlugins: () => api.get('/api/plugins'),
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`)
export const settingsAPI = {
/** @returns {Promise<import('./types').ApiResponse<import('./types').SettingsData>>} */
getSettings: () => api.get('/api/settings'),
/**
* @param {object} data
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
saveSettings: (data) => api.post('/api/settings', data)
}
export default api

57
frontend/src/api/types.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* @typedef {object} ApiResponse<T>
* @property {number} code
* @property {string} message
* @property {T} data
*/
/**
* @typedef {object} StatsData
* @property {number} total
* @property {number} available
* @property {number} today_new
* @property {number} avg_score
* @property {number} http_count
* @property {number} https_count
* @property {number} socks4_count
* @property {number} socks5_count
*/
/**
* @typedef {object} Proxy
* @property {string} ip
* @property {number} port
* @property {string} protocol
* @property {number} score
* @property {string} last_check
*/
/**
* @typedef {object} ProxyListData
* @property {Proxy[]} list
* @property {number} total
*/
/**
* @typedef {object} Plugin
* @property {string|number} id
* @property {string} name
* @property {string} description
* @property {boolean} enabled
* @property {number} success_count
* @property {number} failure_count
* @property {string|null} last_run
*/
/**
* @typedef {object} SettingsData
* @property {string} db_path
* @property {number} crawl_timeout
* @property {number} validation_timeout
* @property {number} max_retries
* @property {number} default_concurrency
* @property {number} min_proxy_score
* @property {number} proxy_expiry_days
*/
export {}

View File

@@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -1,18 +1,35 @@
<template>
<el-card class="header-card" shadow="hover">
<h1 class="title">{{ icon }} {{ title }} {{ icon }}</h1>
<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: '📄'
default: ''
}
})
</script>
@@ -20,15 +37,45 @@ defineProps({
<style scoped>
.header-card {
margin-bottom: 20px;
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.header-card:hover {
border-color: var(--border-light);
}
.title {
text-align: center;
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);
font-size: 28px;
font-weight: 700;
letter-spacing: 2px;
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>

View File

@@ -2,18 +2,28 @@
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">📈 协议分布</span>
<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"></div>
<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: () => ({})
@@ -22,17 +32,51 @@ const props = defineProps({
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
const chartData = computed(() => [
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: '#00D4FF' } },
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: '#00A8CC' } },
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: '#7B68EE' } },
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: '#FF3366' } }
])
// ==================== 计算属性 ====================
const 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 total = computed(() => chartData.value.reduce((sum, item) => sum + item.value, 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',
@@ -40,11 +84,11 @@ function getChartOption() {
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${percent}%)`
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#FF6B9D',
backgroundColor: 'rgba(24, 28, 37, 0.95)',
borderColor: colors.primary,
borderWidth: 1,
textStyle: {
color: '#333',
color: colors.textPrimary,
fontSize: 14
}
},
@@ -53,20 +97,20 @@ function getChartOption() {
right: 10,
top: 'center',
textStyle: {
color: '#666',
fontSize: 14
color: colors.textSecondary,
fontSize: 13
},
itemGap: 20
itemGap: 16
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['35%', '50%'],
radius: ['40%', '65%'],
center: ['38%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#FFFFFF',
borderRadius: 6,
borderColor: colors.surface,
borderWidth: 2
},
label: {
@@ -75,15 +119,15 @@ function getChartOption() {
emphasis: {
label: {
show: true,
fontSize: 18,
fontSize: 16,
fontWeight: 'bold',
color: '#333',
color: colors.textPrimary,
formatter: '{b}\n{c}个'
},
itemStyle: {
shadowBlur: 8,
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 107, 157, 0.2)'
shadowColor: 'rgba(146, 124, 255, 0.3)'
}
},
animationType: 'scale',
@@ -96,8 +140,9 @@ function getChartOption() {
}
function initChart() {
if (!chartRef.value) return
if (!chartRef.value || !hasData.value) return
loadColors()
chartInstance = echarts.init(chartRef.value)
updateChart()
@@ -105,35 +150,79 @@ function initChart() {
}
function updateChart() {
if (!chartInstance) return
if (!chartInstance || !hasData.value) return
chartInstance.setOption(getChartOption(), true)
}
function handleResize() {
chartInstance?.resize()
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, () => {
updateChart()
if (!chartInstance && hasData.value) {
initChart()
} else {
updateChart()
}
}, { deep: true })
// ==================== 生命周期 ====================
onMounted(() => {
initChart()
if (hasData.value) {
initChart()
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
destroyChart()
})
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-xl);
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>

View File

@@ -1,83 +1,144 @@
<template>
<el-card class="chart-card" shadow="hover">
<el-card class="actions-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">🎯 快速操作</span>
<span class="card-title">
<el-icon class="header-icon"><Lightning /></el-icon>
快速操作
</span>
</div>
</template>
<div class="quick-actions">
<el-button
type="primary"
size="large"
class="action-btn"
:loading="loading"
@click="$emit('startCrawler')"
>
<span class="btn-icon">🚀</span>
立即更新
</el-button>
<el-button
type="success"
size="large"
class="action-btn"
<button
class="action-btn btn-success"
@click="$emit('export')"
>
<span class="btn-icon">📥</span>
导出代理
</el-button>
<el-button
type="warning"
size="large"
class="action-btn"
<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-icon">🧹</span>
清理无效
</el-button>
<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>
defineProps({
loading: {
type: Boolean,
default: false
}
})
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
defineEmits(['start-crawler', 'export', 'clean'])
defineEmits(['export', 'clean'])
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-xl);
.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: flex;
flex-direction: column;
gap: 15px;
padding: 20px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.action-btn {
width: 100%;
height: 60px;
font-size: 16px;
border-radius: 14px;
font-weight: 700;
letter-spacing: 0.5px;
box-shadow: var(--shadow-md);
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;
}
.action-btn:hover {
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.25);
transform: translateY(-5px) scale(1.02);
.btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-icon {
font-size: 20px;
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>

View File

@@ -1,9 +1,11 @@
<template>
<el-card :class="['stat-card', type]" shadow="hover">
<div class="stat-content">
<div class="stat-icon">{{ icon }}</div>
<el-icon v-if="icon" class="stat-icon" :size="28">
<component :is="icon" />
</el-icon>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-value" :title="String(value)">{{ displayValue }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
@@ -11,88 +13,124 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
/**
* 统计卡片组件 - 冷灰紫主题
* @description 用于展示 Dashboard 上的统计数据
*/
const props = defineProps({
/** 卡片类型,影响背景色 */
type: {
type: String,
default: 'default'
default: 'default',
validator: (value) => ['default', 'total', 'available', 'new', 'score'].includes(value)
},
/** 图标组件 */
icon: {
type: String,
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-xl);
min-height: 120px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 107, 157, 0.15);
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 {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 8px 24px rgba(255, 107, 157, 0.15);
border-color: var(--cyan);
border-color: var(--border-light);
transform: translateY(-2px);
}
.stat-card.total {
background-color: rgba(0, 212, 255, 0.1);
/* 不同类型卡片的图标颜色区分 */
.stat-card.total .stat-icon {
color: var(--info);
filter: drop-shadow(0 0 8px rgba(56, 189, 248, 0.4));
}
.stat-card.available {
background-color: rgba(0, 255, 136, 0.1);
.stat-card.available .stat-icon {
color: var(--success);
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
}
.stat-card.new {
background-color: rgba(255, 184, 0, 0.1);
.stat-card.new .stat-icon {
color: var(--warning);
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
}
.stat-card.score {
background-color: rgba(168, 85, 247, 0.1);
.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: 10px;
padding: 4px;
}
.stat-icon {
font-size: 32px;
margin-right: 20px;
filter: drop-shadow(0 0 15px rgba(255, 107, 157, 0.3));
margin-right: 16px;
flex-shrink: 0;
color: var(--text-secondary);
}
.stat-info {
flex: 1;
text-align: left;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 5px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
letter-spacing: 0.5px;
}
@media (max-width: 768px) {
.stat-value {
font-size: 22px;
}
.stat-icon {
margin-right: 12px;
}
}
</style>

View File

@@ -1,76 +0,0 @@
import { ref } from 'vue'
export function useWebSocket() {
const ws = ref(null)
const isExplicitDisconnect = ref(false)
let reconnectTimer = null
function connect(url, onMessage, onError, onClose, onOpen, token) {
isExplicitDisconnect.value = false
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
console.log('WebSocket已经连接啦~')
return
}
const wsUrl = token ? `${url}?token=${token}` : url
console.log('尝试连接WebSocket:', wsUrl)
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
console.log('WebSocket连接成功啦~', ws.value.readyState)
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
onOpen?.()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage?.(data)
} catch (error) {
console.error('解析WebSocket消息失败:', error, event.data)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
onError?.(error)
}
ws.value.onclose = (event) => {
console.log('WebSocket连接关闭:', event.code, event.reason)
ws.value = null
onClose?.(event)
if (!isExplicitDisconnect.value) {
console.log('检测到异常断开3秒后尝试重连...')
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect(url, onMessage, onError, onClose, onOpen)
}, 3000)
}
}
}
function disconnect() {
isExplicitDisconnect.value = true
if (ws.value) {
ws.value.close()
ws.value = null
}
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
return {
ws,
connect,
disconnect
}
}

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
@@ -15,11 +15,7 @@ const routes = [
name: 'ProxyList',
component: () => import('../views/ProxyList.vue')
},
{
path: '/crawler',
name: 'CrawlerTasks',
component: () => import('../views/CrawlerTasks.vue')
},
{
path: '/plugins',
name: 'Plugins',
@@ -33,7 +29,7 @@ const routes = [
]
const router = createRouter({
history: createWebHistory(),
history: createWebHashHistory(),
routes
})

View File

@@ -1,144 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { crawlerAPI, schedulerAPI } from '../api'
import { useWebSocket } from '../composables/useWebSocket'
export const useCrawlerStore = defineStore('crawler', () => {
const running = ref(false)
const stats = ref({})
const scheduled = ref(false)
const intervalMinutes = ref(60)
const progress = ref({
total: 0,
current: 0,
success: 0,
failed: 0
})
const statusMessage = ref('')
const { connect, disconnect } = useWebSocket()
async function fetchStatus() {
try {
const response = await crawlerAPI.getStatus()
if (response.code === 200) {
running.value = response.data.running
stats.value = response.data.stats || {}
}
} catch (error) {
console.error('获取爬虫状态失败:', error)
}
}
async function startCrawler(numValidators = 50) {
try {
const response = await crawlerAPI.start(numValidators)
if (response.code === 200) {
running.value = true
return true
}
} catch (error) {
console.error('启动爬虫失败:', error)
}
return false
}
async function stopCrawler() {
try {
const response = await crawlerAPI.stop()
if (response.code === 200) {
running.value = false
return true
}
} catch (error) {
console.error('停止爬虫失败:', error)
}
return false
}
async function fetchSchedulerStatus() {
try {
const response = await schedulerAPI.getStatus()
if (response.code === 200) {
scheduled.value = response.data.enabled
intervalMinutes.value = response.data.interval_minutes
}
} catch (error) {
console.error('获取定时任务状态失败:', error)
}
}
async function setScheduler(enabled, interval = 60) {
try {
const response = await schedulerAPI.setScheduler(enabled, interval)
if (response.code === 200) {
scheduled.value = enabled
intervalMinutes.value = interval
return true
}
} catch (error) {
console.error('设置定时任务失败:', error)
}
return false
}
function connectWebSocket() {
const wsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8923'
const token = import.meta.env.VITE_API_KEY
connect(
`${wsUrl}/ws`,
(data) => {
console.log('收到WebSocket消息:', data)
if (data.type === 'progress') {
console.log('更新进度:', data.data)
progress.value = {
found: data.data.found || 0,
verified: data.data.verified || 0,
success_rate: data.data.success_rate || 0
}
console.log('进度更新后:', progress.value)
} else if (data.type === 'status') {
statusMessage.value = data.data.message
if (data.data.status === 'completed') {
running.value = false
} else if (data.data.status === 'stopped') {
running.value = false
} else if (data.data.status === 'running') {
running.value = true
}
}
},
(error) => {
console.error('WebSocket错误:', error)
},
(event) => {
console.log('WebSocket连接关闭:', event.code, event.reason)
},
() => {
console.log('WebSocket连接成功啦~')
},
token
)
}
function disconnectWebSocket() {
disconnect()
}
return {
running,
stats,
scheduled,
intervalMinutes,
progress,
statusMessage,
fetchStatus,
startCrawler,
stopCrawler,
fetchSchedulerStatus,
setScheduler,
connectWebSocket,
disconnectWebSocket
}
})

View File

@@ -1,25 +1,48 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { pluginsAPI } from '../api'
/**
* 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 pluginsAPI.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 pluginsAPI.togglePlugin(pluginId, enabled)
@@ -35,24 +58,50 @@ export const usePluginsStore = defineStore('plugins', () => {
}
return false
}
/**
* 触发插件爬取
* @param {string|number} pluginId
* @returns {Promise<boolean>}
*/
async function crawlPlugin(pluginId) {
try {
const response = await pluginsAPI.crawlPlugin(pluginId)
if (response.code === 200) {
return true
}
return response.code === 200
} catch (error) {
console.error('触发插件爬取失败:', error)
return false
}
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
crawlPlugin,
getPluginById,
reset
}
})

View File

@@ -2,54 +2,99 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { proxiesAPI, statsAPI } from '../api'
/**
* 判断是否为用户取消的错误
* @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({})
const availableCount = computed(() => stats.value.available || 0)
const totalCount = computed(() => stats.value.total || 0)
// ==================== 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 statsAPI.getStats()
if (response.code === 200) {
stats.value = response.data
return true
}
} catch (error) {
console.error('获取统计信息失败:', error)
}
return false
}
async function fetchProxies(params) {
/**
* 获取代理列表
* @param {object} params - 查询参数
* @param {AbortSignal} [signal] - 用于取消请求的信号
* @returns {Promise<boolean>}
*/
async function fetchProxies(params, signal) {
loading.value = true
try {
const response = await proxiesAPI.getProxies(params)
const response = await proxiesAPI.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
}
}
async function deleteProxy(ip, port) {
try {
const response = await proxiesAPI.deleteProxy(ip, port)
if (response.code === 200) {
return true
}
} catch (error) {
console.error('删除代理失败:', error)
}
return false
}
/**
* 删除单个代理
* @param {string} ip
* @param {number|string} port
* @returns {Promise<boolean>}
*/
async function deleteProxy(ip, port) {
try {
const response = await proxiesAPI.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 proxiesAPI.batchDeleteProxies(proxyList)
if (response.code === 200) {
@@ -61,6 +106,10 @@ export const useProxyStore = defineStore('proxy', () => {
return 0
}
/**
* 清理无效代理
* @returns {Promise<number>} 删除的数量
*/
async function cleanInvalidProxies() {
try {
const response = await proxiesAPI.cleanInvalidProxies()
@@ -73,9 +122,17 @@ export const useProxyStore = defineStore('proxy', () => {
return 0
}
async function exportProxies(format, protocol) {
/**
* 导出代理
* @param {string} format - 导出格式 (txt/csv/json)
* @param {string|null} protocol - 协议过滤
* @returns {Promise<boolean>}
*/
async function exportProxies(format, protocol = null) {
try {
const response = await proxiesAPI.exportProxies(format, protocol)
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response]))
const link = document.createElement('a')
link.href = url
@@ -84,25 +141,39 @@ export const useProxyStore = defineStore('proxy', () => {
link.click()
link.remove()
window.URL.revokeObjectURL(url)
return true
} catch (error) {
console.error('导出代理失败:', error)
return false
}
return false
}
/**
* 重置状态
*/
function reset() {
proxies.value = []
total.value = 0
stats.value = {}
}
return {
// State
proxies,
total,
loading,
stats,
availableCount,
totalCount,
// Getters
hasProxies,
isEmpty,
// Actions
fetchStats,
fetchProxies,
deleteProxy,
batchDeleteProxies,
cleanInvalidProxies,
exportProxies
exportProxies,
reset
}
})

View File

@@ -9,43 +9,53 @@
}
body {
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-page);
background: var(--bg);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
text-decoration: none;
color: var(--cyan);
color: var(--primary);
transition: var(--transition-base);
}
a:hover {
color: var(--cyan-light);
color: var(--primary-hover);
}
/* 滚动条 - 深色主题 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: var(--bg-page);
background-color: var(--bg);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb {
background-color: var(--border-light);
background-color: var(--border);
border-radius: var(--radius-sm);
transition: var(--transition-base);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--primary-light);
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%;
@@ -84,11 +94,11 @@ a:hover {
}
}
@keyframes progressShine {
0% {
transform: translateX(-100%);
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 5px rgba(146, 124, 255, 0.3);
}
100% {
transform: translateX(100%);
50% {
box-shadow: 0 0 20px rgba(146, 124, 255, 0.5);
}
}

View File

@@ -1,5 +1,10 @@
/* ==================== 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,
@@ -7,8 +12,23 @@
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,
@@ -16,55 +36,67 @@
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-secondary) !important;
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-md) !important;
background: white !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
color: var(--text-secondary) !important;
}
.el-select-dropdown__item:hover {
background: rgba(255, 107, 157, 0.1) !important;
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(--bg-light) !important;
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: rgba(255, 107, 157, 0.1) !important;
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: #ccc !important;
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;
}
@@ -73,79 +105,132 @@
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--primary {
background: var(--gradient-primary) !important;
.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 {
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3) !important;
transform: translateY(-2px);
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(--gradient-cyan) !important;
border-color: var(--cyan) !important;
color: white !important;
background: var(--success) !important;
border-color: var(--success) !important;
color: var(--bg) !important;
}
.el-button--success:hover {
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3) !important;
transform: translateY(-2px);
background: #2DD4BF !important;
border-color: #2DD4BF !important;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3) !important;
}
/* 警告按钮 - 橙黄 */
.el-button--warning {
background: var(--gradient-yellow) !important;
border-color: var(--yellow) !important;
color: white !important;
background: var(--warning) !important;
border-color: var(--warning) !important;
color: var(--bg) !important;
}
.el-button--warning:hover {
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3) !important;
transform: translateY(-2px);
background: #FBBF24 !important;
border-color: #FBBF24 !important;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
}
/* 危险按钮 - 粉红 */
.el-button--danger {
background: var(--gradient-danger) !important;
background: var(--danger) !important;
border-color: var(--danger) !important;
color: white !important;
}
.el-button--danger:hover {
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3) !important;
transform: translateY(-2px);
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: var(--shadow-sm) !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: var(--bg-card) !important;
background: transparent !important;
padding: 20px;
}
/* -------------------- 表格 -------------------- */
.el-table {
border: 1px solid var(--border) !important;
background: white !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(--bg-light) !important;
color: var(--text-primary) !important;
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;
}
@@ -158,16 +243,22 @@
}
.el-table tr:hover > td {
background: #FFF0F5 !important;
background: var(--surface-2) !important;
}
.el-table__body tr.current-row > td.el-table__cell {
background: var(--border) !important;
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: white !important;
background: var(--surface-3) !important;
}
.el-checkbox__inner:hover {
@@ -180,61 +271,86 @@
}
.el-checkbox__input.is-disabled .el-checkbox__inner {
background: #f5f5f5 !important;
border-color: #e4e7ed !important;
background: var(--el-disabled-bg) !important;
border-color: var(--el-disabled-border) !important;
}
/* -------------------- 分页 -------------------- */
.el-pagination button {
border: 1px solid var(--border) !important;
background: var(--bg-light) !important;
background: var(--surface) !important;
color: var(--text-secondary) !important;
border-radius: var(--radius-sm) !important;
}
.el-pagination button:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--surface-2) !important;
border-color: var(--primary) !important;
color: var(--primary) !important;
}
.el-pagination li.is-active {
background: var(--primary) !important;
color: white !important;
border-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(--bg-light) !important;
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: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
border-color: rgba(255, 107, 157, 0.3) !important;
border-color: rgba(146, 124, 255, 0.3) !important;
}
.el-tag--success {
background: rgba(0, 212, 255, 0.1) !important;
color: var(--cyan) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
background: var(--success-soft) !important;
color: var(--success) !important;
border-color: rgba(34, 197, 94, 0.3) !important;
}
.el-tag--warning {
background: rgba(255, 184, 0, 0.1) !important;
color: var(--yellow) !important;
border-color: rgba(255, 184, 0, 0.3) !important;
background: var(--warning-soft) !important;
color: var(--warning) !important;
border-color: rgba(245, 158, 11, 0.3) !important;
}
.el-tag--danger {
background: rgba(255, 107, 107, 0.1) !important;
background: var(--danger-soft) !important;
color: var(--danger) !important;
border-color: rgba(255, 107, 107, 0.3) !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;
}
@@ -243,36 +359,54 @@
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: white !important;
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-md) !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-dropdown-menu__item {
color: var(--text-primary) !important;
color: var(--text-secondary) !important;
}
.el-dropdown-menu__item:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
}
/* -------------------- 滚动条 -------------------- */
.el-scrollbar__wrap::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -287,46 +421,54 @@
background: var(--primary);
}
/* -------------------- 表单 -------------------- */
.el-form-item__label {
color: var(--text-muted) !important;
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-md) !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-message--success {
background: rgba(0, 212, 255, 0.1) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
color: var(--cyan) !important;
background: var(--surface) !important;
border-color: var(--success) !important;
color: var(--success) !important;
}
.el-message--error {
background: rgba(255, 107, 107, 0.1) !important;
border-color: rgba(255, 107, 107, 0.3) !important;
background: var(--surface) !important;
border-color: var(--danger) !important;
color: var(--danger) !important;
}
.el-message--warning {
background: rgba(255, 184, 0, 0.1) !important;
border-color: rgba(255, 184, 0, 0.3) !important;
color: var(--yellow) !important;
background: var(--surface) !important;
border-color: var(--warning) !important;
color: var(--warning) !important;
}
.el-message--info {
background: rgba(255, 107, 157, 0.1) !important;
border-color: rgba(255, 107, 157, 0.3) !important;
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-md) !important;
box-shadow: var(--shadow-xl) !important;
background: var(--surface) !important;
border-radius: var(--radius-lg) !important;
}
.el-message-box__header {
@@ -334,33 +476,118 @@
}
.el-message-box__title {
color: var(--primary) !important;
color: var(--text-primary) !important;
font-weight: 600;
}
.el-message-box__content {
color: var(--text-primary) !important;
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: rgba(0, 255, 136, 0.1) !important;
border-color: var(--green) !important;
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: rgba(255, 107, 157, 0.1) !important;
border-color: var(--primary) !important;
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: rgba(255, 184, 0, 0.1) !important;
border-color: var(--yellow) !important;
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: rgba(255, 51, 102, 0.1) !important;
border-color: var(--danger) !important;
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;
}

View File

@@ -1,15 +1,19 @@
/**
* 工具类 CSS - 冷灰紫主题
* 提供通用的布局和样式工具类
*/
/* ==================== 卡片 ==================== */
.card-base {
border-radius: var(--radius-xl);
background: var(--bg-card);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
transition: var(--transition-hover);
}
.card-base:hover {
box-shadow: var(--shadow-md);
border-color: var(--border-light);
transform: translateY(-2px);
border-color: var(--primary);
}
.card-header {
@@ -22,14 +26,15 @@
.card-title {
font-size: 18px;
font-weight: 700;
color: var(--primary);
letter-spacing: 1px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
}
/* ==================== 按钮工具类 ==================== */
.btn-base {
border-radius: var(--radius-md);
font-weight: 600;
font-weight: 500;
transition: var(--transition-base);
border: 1px solid var(--border);
display: inline-flex;
@@ -38,61 +43,87 @@
gap: 8px;
cursor: pointer;
outline: none;
padding: 8px 16px;
background: var(--surface-2);
color: var(--text-secondary);
}
.btn-primary {
background: var(--gradient-primary);
.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 {
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3);
transform: translateY(-2px);
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(--gradient-cyan);
border-color: var(--cyan);
color: white;
background: var(--success);
border-color: var(--success);
color: var(--bg);
}
.btn-success:hover {
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transform: translateY(-2px);
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(--gradient-yellow);
border-color: var(--yellow);
color: white;
background: var(--warning);
border-color: var(--warning);
color: var(--bg);
}
.btn-warning:hover {
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3);
transform: translateY(-2px);
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(--gradient-danger);
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover {
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
transform: translateY(-2px);
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: 20px;
font-size: 18px;
margin-right: 0;
vertical-align: middle;
}
/* ==================== 布局 ==================== */
.page-container {
padding: 20px;
background: var(--bg-page);
padding: 24px;
background: var(--bg);
min-height: 100vh;
}
@@ -104,7 +135,7 @@
.form-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
align-items: flex-end;
}
@@ -114,12 +145,19 @@
gap: 20px;
}
@media (max-width: 768px) {
@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;
@@ -137,50 +175,103 @@
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-1 {
flex: 1;
}
/* ==================== 文字颜色 ==================== */
.text-primary {
color: var(--primary);
}
.text-cyan {
color: var(--cyan);
.text-accent {
color: var(--accent);
}
.text-success {
color: var(--green);
color: var(--success);
}
.text-warning {
color: var(--yellow);
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-cyan {
background: var(--gradient-cyan);
.bg-gradient-accent {
background: var(--gradient-accent);
}
.border-pink {
.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);
}
.rounded-xl {
border-radius: var(--radius-xl);
.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);
}
@@ -188,3 +279,156 @@
.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;
}

View File

@@ -1,43 +1,87 @@
/**
* CSS 变量定义 - 冷灰紫主题
* 设计理念:冷灰做信息底座,克制紫色做品牌识别和交互强调
* 参考Material 3 颜色角色体系
*/
:root {
--primary: #FF6B9D;
--primary-light: #FF8FB3;
--primary-dark: #FF5A8F;
--cyan: #00D4FF;
--cyan-light: #00E5FF;
--cyan-dark: #00B8E0;
--green: #34D399;
--yellow: #FFB800;
--danger: #FF6B6B;
--purple: #A855F7;
--bg-page: #FAFAFA;
--bg-card: #FFFFFF;
--bg-light: #FFF9FB;
--text-primary: #333333;
--text-secondary: #999999;
--text-muted: #666666;
--border: #FFE4EC;
--border-light: #FFD6E3;
--gradient-primary: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%);
--gradient-cyan: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%);
--gradient-yellow: linear-gradient(135deg, #FFB800 0%, #FFD000 100%);
--gradient-danger: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%);
/* ==================== 背景层次 (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: 8px;
--radius-md: 10px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--shadow-sm: 0 2px 8px rgba(255, 107, 157, 0.08);
--shadow-md: 0 4px 12px rgba(255, 107, 157, 0.15);
--shadow-lg: 0 8px 20px rgba(255, 107, 157, 0.2);
--transition-base: all 0.3s ease;
--transition-hover: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--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;
}

View File

@@ -0,0 +1,63 @@
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)
}
/**
* 复制文本(备选方案:使用 DOM
* @param {string} text
* @returns {boolean}
*/
export function copyTextFallback(text) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0;'
document.body.appendChild(textarea)
try {
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand('copy')
document.body.removeChild(textarea)
return success
} catch {
document.body.removeChild(textarea)
return false
}
}

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

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

View File

@@ -1,385 +0,0 @@
<template>
<div class="page-container">
<PageHeader title="任务管理" icon="🎀" />
<el-card class="control-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">🎮 任务控制</span>
<el-tag :type="crawler.running ? 'success' : 'info'" size="large">
{{ crawler.running ? '运行中' : '已停止' }}
</el-tag>
</div>
</template>
<div class="control-content">
<div class="control-item">
<label class="control-label">验证并发数</label>
<el-input-number
v-model="numValidators"
:min="10"
:max="200"
:step="10"
size="large"
class="control-input"
/>
</div>
<div class="control-actions">
<el-button
type="primary"
size="large"
@click="handleStart"
:loading="crawler.running"
:disabled="crawler.running"
>
<span class="btn-icon">🚀</span>
开始任务
</el-button>
<el-button
type="danger"
size="large"
@click="handleStop"
:disabled="!crawler.running"
>
<span class="btn-icon"></span>
停止任务
</el-button>
</div>
</div>
</el-card>
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">📊 任务进度</span>
</div>
</template>
<div class="progress-content">
<div class="progress-item">
<div class="progress-label">爬取进度</div>
<el-progress
:percentage="crawlProgress"
:stroke-width="24"
class="progress-bar"
color="#FF6B9D"
>
<span class="progress-text">成功率 {{ crawler.progress.success_rate }}%</span>
</el-progress>
</div>
<div class="progress-item">
<div class="progress-label">验证统计</div>
<div class="stats-grid">
<div class="stat-item success">
<span class="stat-label">发现</span>
<span class="stat-value">{{ crawler.progress.found }}</span>
</div>
<div class="stat-item verified">
<span class="stat-label">验证通过</span>
<span class="stat-value">{{ crawler.progress.verified }}</span>
</div>
</div>
</div>
<div class="status-box">
<div class="status-item">
<span class="status-label">状态</span>
<span class="status-value">{{ crawler.statusMessage || '等待中...' }}</span>
</div>
<div class="status-item" v-if="crawler.stats.start_time">
<span class="status-label">开始时间</span>
<span class="status-value">{{ formatTime(crawler.stats.start_time) }}</span>
</div>
<div class="status-item" v-if="crawler.stats.plugins?.length">
<span class="status-label">加载插件</span>
<span class="status-value">{{ crawler.stats.plugins.length }} </span>
</div>
</div>
</div>
</el-card>
<el-card class="scheduled-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title"> 定时任务</span>
<el-switch
v-model="crawler.scheduled"
@change="handleSchedulerChange"
size="large"
active-color="#FF6B9D"
inactive-color="#dcdfe6"
/>
</div>
</template>
<div class="scheduled-content">
<div class="scheduled-item">
<label class="scheduled-label">执行间隔分钟</label>
<el-input-number
v-model="crawler.intervalMinutes"
:min="10"
:max="1440"
:step="10"
size="large"
:disabled="!crawler.scheduled"
class="scheduled-input"
@change="handleIntervalChange"
/>
</div>
<div class="scheduled-info">
<el-alert
:title="crawler.scheduled ? '定时任务已启用' : '定时任务已停用'"
:type="crawler.scheduled ? 'success' : 'info'"
:description="crawler.scheduled ? `每 ${crawler.intervalMinutes} 分钟自动执行一次爬取任务~` : '开启定时任务可以自动定期更新代理池哦~'"
show-icon
:closable="false"
/>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useCrawlerStore } from '../stores/crawler'
import PageHeader from '../components/PageHeader.vue'
const crawler = useCrawlerStore()
const numValidators = ref(50)
const crawlProgress = computed(() => {
if (!crawler.running || crawler.progress.total === 0) return 0
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
})
function formatTime(timeStr) {
if (!timeStr) return '-'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
}
async function handleStart() {
const success = await crawler.startCrawler(numValidators.value)
if (success) {
ElMessage.success('爬虫任务开始啦~')
}
}
async function handleStop() {
const success = await crawler.stopCrawler()
if (success) {
ElMessage.success('爬虫任务已停止~')
}
}
async function handleSchedulerChange(enabled) {
const success = await crawler.setScheduler(enabled, crawler.intervalMinutes)
if (success) {
ElMessage.success(enabled ? '定时任务已启动~' : '定时任务已停止~')
}
}
async function handleIntervalChange() {
if (crawler.scheduled) {
const success = await crawler.setScheduler(true, crawler.intervalMinutes)
if (success) {
ElMessage.success(`定时任务间隔已更新为 ${crawler.intervalMinutes} 分钟~`)
}
}
}
onMounted(async () => {
await crawler.fetchStatus()
await crawler.fetchSchedulerStatus()
crawler.connectWebSocket()
})
onUnmounted(() => {
crawler.disconnectWebSocket()
})
</script>
<style scoped>
.control-card {
margin-bottom: 20px;
border-radius: var(--radius-xl);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--primary);
}
.control-content {
padding: 20px;
}
.control-item {
display: flex;
align-items: center;
margin-bottom: 30px;
}
.control-label {
font-size: 16px;
color: var(--text-muted);
margin-right: 20px;
min-width: 100px;
}
.control-input {
width: 200px;
}
.control-actions {
display: flex;
gap: 20px;
justify-content: center;
}
.btn-icon {
font-size: 20px;
margin-right: 8px;
}
.progress-card {
margin-bottom: 20px;
border-radius: var(--radius-xl);
}
.progress-content {
padding: 20px;
}
.progress-item {
margin-bottom: 30px;
}
.progress-label {
font-size: 16px;
color: var(--text-muted);
margin-bottom: 15px;
font-weight: 600;
}
.progress-bar {
margin-bottom: 10px;
}
.progress-text {
font-size: 14px;
color: var(--primary);
font-weight: 600;
}
.status-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
padding: 20px;
background: #FFF0F5;
border-radius: 12px;
}
.status-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-label {
font-size: 14px;
color: var(--text-secondary);
}
.status-value {
font-size: 16px;
color: var(--primary);
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.stat-item {
padding: 15px;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.stat-item.success {
background: rgba(52, 211, 153, 0.1);
border: 2px solid var(--green);
}
.stat-item.verified {
background: rgba(255, 107, 157, 0.1);
border: 2px solid var(--primary);
}
.stat-label {
font-size: 14px;
color: var(--text-muted);
font-weight: 600;
}
.stat-value {
font-size: 24px;
font-weight: 700;
}
.stat-item.success .stat-value {
color: var(--green);
}
.stat-item.verified .stat-value {
color: var(--primary);
}
.scheduled-card {
border-radius: var(--radius-xl);
}
.scheduled-content {
padding: 20px;
}
.scheduled-item {
display: flex;
align-items: center;
margin-bottom: 30px;
}
.scheduled-label {
font-size: 16px;
color: var(--text-muted);
margin-right: 20px;
min-width: 150px;
}
.scheduled-input {
width: 200px;
}
.scheduled-info {
padding: 10px;
}
</style>

View File

@@ -1,133 +1,173 @@
<template>
<div class="page-container">
<PageHeader title="代理池管理系统" icon="🔮" />
<PageHeader title="代理池管理系统" :icon="MagicStick" />
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<StatCard type="total" icon="📊" :value="stats.total || 0" label="总代理数" />
<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 :span="6">
<StatCard type="available" icon="✨" :value="stats.available || 0" label="可用数量" />
<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 :span="6">
<StatCard type="new" icon="🎉" :value="stats.today_new || 0" label="今日新增" />
<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 :span="6">
<StatCard type="score" icon="⭐" :value="(stats.avg_score || 0).toFixed(1)" label="平均分数" />
<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 :span="16">
<el-col :xs="24" :lg="16">
<ProtocolChart :data="stats" />
</el-col>
<el-col :span="8">
<QuickActions :loading="crawler.running" @start-crawler="handleStartCrawler" @export="handleExport" @clean="handleClean" />
<el-col :xs="24" :lg="8">
<QuickActions
@export="handleExport"
@clean="handleClean"
/>
</el-col>
</el-row>
<el-card class="status-card" shadow="hover" v-if="crawler.running">
<template #header>
<div class="card-header">
<span class="card-title">🔄 当前任务状态</span>
</div>
</template>
<div class="status-content">
<el-progress
:percentage="progressPercentage"
:stroke-width="20"
class="progress-bar"
>
<span class="progress-text">
发现 {{ crawler.progress.found }} 验证通过 {{ crawler.progress.verified }} 成功率 {{ crawler.progress.success_rate }}%
</span>
</el-progress>
<div class="status-message">{{ crawler.statusMessage }}</div>
</div>
</el-card>
<!-- 系统状态 -->
<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 { ref, computed, onMounted, onUnmounted, watch } from 'vue'
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 { useCrawlerStore } from '../stores/crawler'
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 crawler = useCrawlerStore()
// ==================== 计算属性 ====================
const stats = computed(() => proxyStore.stats)
const avgScore = computed(() => formatNumber(stats.value.avg_score || 0, 1))
watch(() => crawler.running, async (newVal, oldVal) => {
if (oldVal === true && newVal === false) {
await proxyStore.fetchStats()
initCharts()
ElMessage.success('任务完成,数据已更新~')
}
})
const progressPercentage = computed(() => {
if (crawler.progress.total === 0) return 0
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
})
// ==================== 定时刷新 ====================
const REFRESH_INTERVAL = 5000
let refreshTimer = null
let isPageVisible = true
async function handleStartCrawler() {
try {
await ElMessageBox.confirm('确定要开始爬取代理吗?这可能需要一些时间哦~', '提示', {
confirmButtonText: '开始吧~',
cancelButtonText: '再等等',
type: 'info'
})
const success = await crawler.startCrawler(50)
if (success) {
ElMessage.success('爬虫任务开始啦~')
}
} catch {
}
}
async function handleExport() {
const success = await proxyStore.exportProxies('txt')
if (success) {
ElMessage.success('代理导出成功啦~')
}
}
async function handleClean() {
try {
await ElMessageBox.confirm('确定要清理所有无效代理吗?', '提示', {
confirmButtonText: '清理吧~',
cancelButtonText: '再等等',
type: 'warning'
})
const deletedCount = await proxyStore.cleanInvalidProxies()
if (deletedCount >= 0) {
ElMessage.success(`清理了 ${deletedCount} 个无效代理啦~`)
await proxyStore.fetchStats()
}
} catch {
function handleVisibilityChange() {
isPageVisible = !document.hidden
if (isPageVisible) {
refreshData()
}
}
async function refreshData() {
await proxyStore.fetchStats()
await crawler.fetchStatus()
}
// ==================== 事件处理 ====================
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()
crawler.connectWebSocket()
refreshTimer = setInterval(refreshData, 5000)
document.addEventListener('visibilitychange', handleVisibilityChange)
refreshTimer = setInterval(() => {
if (isPageVisible) {
refreshData()
}
}, REFRESH_INTERVAL)
})
onUnmounted(() => {
@@ -135,7 +175,7 @@ onUnmounted(() => {
clearInterval(refreshTimer)
refreshTimer = null
}
crawler.disconnectWebSocket()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script>
@@ -148,42 +188,62 @@ onUnmounted(() => {
margin-bottom: 20px;
}
.status-row {
margin-bottom: 20px;
}
.status-card {
border-radius: var(--radius-xl);
margin-bottom: 20px;
backdrop-filter: blur(10px);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.status-card:hover {
border-color: rgba(255, 107, 157, 0.4);
box-shadow: 0 8px 32px rgba(255, 107, 157, 0.2);
}
.status-content {
padding: 20px;
}
.progress-bar {
margin-bottom: 20px;
}
.progress-text {
font-size: 14px;
color: var(--primary);
font-weight: 700;
text-shadow: 0 0 10px rgba(255, 107, 157, 0.3);
}
.status-message {
text-align: center;
.card-header {
font-size: 16px;
color: var(--text-secondary);
padding: 15px;
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
border: 1px solid rgba(0, 212, 255, 0.1);
font-weight: 600;
letter-spacing: 0.5px;
animation: fadeIn 0.5s ease;
}
.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>

View File

@@ -1,55 +1,68 @@
<template>
<div class="page-container">
<PageHeader title="插件管理" icon="🔌" />
<PageHeader title="插件管理" :icon="Connection" />
<el-card class="plugins-card" shadow="hover" v-loading="pluginsStore.loading">
<template #header>
<div class="card-header">
<span class="card-title">📦 插件列表</span>
<el-button type="primary" @click="handleRefresh" size="large">
<span class="btn-icon">🔄</span>
刷新列表
</el-button>
<div 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" stripe>
<el-table-column prop="name" label="插件名称" width="200">
<el-table :data="pluginsStore.plugins">
<el-table-column prop="name" label="插件名称" min-width="180">
<template #default="{ row }">
<div class="plugin-name">
<span class="plugin-icon">🔌</span>
<span>{{ row.name }}</span>
<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="200">
<el-table-column prop="description" label="描述" min-width="220">
<template #default="{ row }">
<span class="plugin-description">{{ row.description }}</span>
<span class="plugin-description">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
@change="(val) => handleToggle(row.id, val)"
active-color="#FF6B9D"
inactive-color="#dcdfe6"
class="theme-switch"
/>
</template>
</el-table-column>
<el-table-column label="统计" width="200">
<el-table-column label="统计" width="180">
<template #default="{ row }">
<div class="plugin-stats">
<div class="stat-item">
<span class="stat-label">成功</span>
<span class="stat-value success">{{ row.success_count }}</span>
<el-icon class="stat-icon success"><CircleCheck /></el-icon>
<span class="stat-value success">{{ row.success_count || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">失败</span>
<span class="stat-value failed">{{ row.failure_count }}</span>
<el-icon class="stat-icon failed"><CircleClose /></el-icon>
<span class="stat-value failed">{{ row.failure_count || 0 }}</span>
</div>
</div>
</template>
@@ -61,49 +74,91 @@
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleCrawl(row.id)"
:loading="crawlingPlugin === row.id"
:disabled="!row.enabled"
>
<span class="btn-icon">🚀</span>
立即爬取
<el-icon class="btn-icon"><Promotion /></el-icon>
爬取
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="pluginsStore.isEmpty"
description="暂无插件"
:image-size="120"
/>
<!-- 爬取结果提示 -->
<el-alert
v-if="lastCrawlResult"
:title="lastCrawlResult.message"
:type="lastCrawlResult.type"
closable
class="crawl-result"
@close="lastCrawlResult = null"
>
<template v-if="lastCrawlResult.data">
<div class="crawl-stats">
<span v-if="lastCrawlResult.data.total_crawled !== undefined">
爬取: {{ lastCrawlResult.data.total_crawled }}
</span>
<span v-if="lastCrawlResult.data.proxy_count !== undefined">
爬取: {{ lastCrawlResult.data.proxy_count }}
</span>
<span v-if="lastCrawlResult.data.valid_count !== undefined" class="valid-count">
有效: {{ lastCrawlResult.data.valid_count }}
</span>
<span v-if="lastCrawlResult.data.invalid_count !== undefined" class="invalid-count">
无效: {{ lastCrawlResult.data.invalid_count }}
</span>
</div>
</template>
</el-alert>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Connection,
Refresh,
Promotion,
CircleCheck,
CircleClose,
Box
} from '@element-plus/icons-vue'
import { usePluginsStore } from '../stores/plugins'
import { pluginsAPI } from '../api'
import { formatTime } from '../utils/format'
import PageHeader from '../components/PageHeader.vue'
const pluginsStore = usePluginsStore()
const crawlingPlugin = ref(null)
const crawlingAll = ref(false)
const lastCrawlResult = ref(null)
function formatTime(timeStr) {
if (!timeStr) return '从未运行'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
}
// ==================== 事件处理 ====================
async function handleRefresh() {
await pluginsStore.fetchPlugins()
ElMessage.success('插件列表已刷新~')
ElMessage.success('插件列表已刷新')
}
async function handleToggle(pluginId, enabled) {
const success = await pluginsStore.togglePlugin(pluginId, enabled)
if (success) {
ElMessage.success(enabled ? '插件已启用~' : '插件已禁用~')
ElMessage.success(enabled ? '插件已启用' : '插件已禁用')
} else {
// 失败时刷新列表恢复状态
await pluginsStore.fetchPlugins()
}
}
@@ -111,15 +166,87 @@ async function handleToggle(pluginId, enabled) {
async function handleCrawl(pluginId) {
try {
crawlingPlugin.value = pluginId
const success = await pluginsStore.crawlPlugin(pluginId)
if (success) {
ElMessage.success('插件开始爬取啦~')
lastCrawlResult.value = null
const response = await pluginsAPI.crawlPlugin(pluginId)
if (response.code === 200) {
lastCrawlResult.value = {
type: 'success',
message: response.message,
data: response.data
}
// 刷新插件统计
await pluginsStore.fetchPlugins()
} else {
lastCrawlResult.value = {
type: 'error',
message: response.message || '爬取失败'
}
}
} catch (error) {
lastCrawlResult.value = {
type: 'error',
message: '爬取过程出错'
}
} finally {
crawlingPlugin.value = null
}
}
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
lastCrawlResult.value = null
const response = await pluginsAPI.crawlAll()
if (response.code === 200) {
lastCrawlResult.value = {
type: 'success',
message: response.message,
data: response.data
}
ElMessage.success('批量爬取完成')
// 刷新插件统计
await pluginsStore.fetchPlugins()
} else {
lastCrawlResult.value = {
type: 'error',
message: response.message || '批量爬取失败'
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量爬取失败:', error)
lastCrawlResult.value = {
type: 'error',
message: '批量爬取过程出错'
}
}
} finally {
crawlingAll.value = false
}
}
// ==================== 生命周期 ====================
onMounted(async () => {
await pluginsStore.fetchPlugins()
})
@@ -127,21 +254,41 @@ onMounted(async () => {
<style scoped>
.plugins-card {
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.card-header {
.plugins-card:hover {
border-color: var(--border-light);
}
.header-left {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 18px;
font-weight: 600;
.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;
@@ -149,12 +296,22 @@ onMounted(async () => {
}
.plugin-icon {
font-size: 20px;
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 {
@@ -164,13 +321,20 @@ onMounted(async () => {
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
.stat-icon {
font-size: 14px;
}
.stat-icon.success {
color: var(--success);
}
.stat-icon.failed {
color: var(--danger);
}
.stat-value {
@@ -179,7 +343,7 @@ onMounted(async () => {
}
.stat-value.success {
color: var(--green);
color: var(--success);
}
.stat-value.failed {
@@ -187,12 +351,26 @@ onMounted(async () => {
}
.last-run {
color: var(--text-secondary);
color: var(--text-muted);
font-size: 14px;
}
.btn-icon {
font-size: 20px;
margin-right: 4px;
.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);
}
</style>

View File

@@ -1,25 +1,37 @@
<template>
<div class="page-container">
<PageHeader title="代理列表" icon="📋" />
<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>
<el-option label="HTTP" value="http"></el-option>
<el-option label="HTTPS" value="https"></el-option>
<el-option label="SOCKS4" value="socks4"></el-option>
<el-option label="SOCKS5" value="socks5"></el-option>
<el-select
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-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>
<el-option label="分数" value="score"></el-option>
<el-option label="更新时间" value="last_check" />
<el-option label="分数" value="score" />
</el-select>
</el-form-item>
</el-form>
@@ -28,16 +40,25 @@
<el-card class="table-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">代理详情</span>
<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-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"><component :is="ArrowDownIcon" /></el-icon>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
@@ -58,41 +79,46 @@
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="scope">
<el-tag :type="getProtocolType(scope.row.protocol)" effect="light">
{{ scope.row.protocol.toUpperCase() }}
<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="scope">
<span class="score-value">{{ scope.row.score || 0 }}</span>
<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="最后检查时间">
<template #default="scope">
{{ formatDateTime(scope.row.last_check) }}
<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="scope">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click.stop="handleCopy(scope.row)"
@click.stop="handleCopy(row)"
>
<el-icon class="btn-icon"><CopyDocument /></el-icon>
复制
</el-button>
<el-button
type="danger"
size="small"
@click.stop="handleDelete(scope.row)"
@click.stop="handleDelete(row)"
>
<el-icon class="btn-icon"><Delete /></el-icon>
删除
</el-button>
</template>
@@ -103,7 +129,7 @@
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:page-sizes="PAGE_SIZE_OPTIONS"
:total="proxyStore.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@@ -115,19 +141,27 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
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 ArrowDownIcon = ArrowDown
/** 分页选项 */
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
/** 默认分页大小 */
const DEFAULT_PAGE_SIZE = 20
const proxyStore = useProxyStore()
const proxyStore = useProxyStore()
// ==================== 状态 ====================
const currentPage = ref(1)
const pageSize = ref(20)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const selectedProxies = ref([])
let abortController = null
const filterForm = reactive({
protocol: '',
@@ -136,29 +170,26 @@ const filterForm = reactive({
sortOrder: 'DESC'
})
// ==================== 协议类型映射 ====================
const PROTOCOL_TYPE_MAP = {
http: 'info',
https: 'success',
socks4: 'warning',
socks5: 'primary'
}
function getProtocolType(protocol) {
const types = {
http: 'primary',
https: 'success',
socks4: 'warning',
socks5: 'danger'
}
return types[protocol] || 'info'
}
function formatDateTime(dateTimeStr) {
if (!dateTimeStr) return '-'
const date = new Date(dateTimeStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
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,
@@ -166,75 +197,55 @@ async function fetchProxies() {
min_score: filterForm.minScore,
sort_by: filterForm.sortBy,
sort_order: filterForm.sortOrder
})
}, abortController.signal)
abortController = null
}
// ==================== 事件处理 ====================
function handleSearch() {
currentPage.value = 1
fetchProxies()
}
function handleReset() {
filterForm.protocol = ''
filterForm.minScore = 0
filterForm.sortBy = 'last_check'
currentPage.value = 1
fetchProxies()
}
function handleSelectionChange(selection) {
selectedProxies.value = selection.map(item => [item.ip, item.port])
}
async function handleCopy(proxy) {
const text = `${proxy.ip}:${proxy.port}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success(`已复制 ${text} 到剪贴板啦~`)
} catch {
ElMessage.error('复制失败呢~')
}
await copyProxy(proxy)
}
async function handleDelete(proxy) {
try {
await ElMessageBox.confirm(`确定要删除代理 ${proxy.ip}:${proxy.port} 吗?`, '提示', {
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
})
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
if (success) {
ElMessage.success('删除成功啦~')
fetchProxies()
}
} catch {
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() {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedProxies.value.length} 个代理吗?`, '提示', {
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
})
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
if (deletedCount > 0) {
ElMessage.success(`批量删除成功啦~共删除了 ${deletedCount} 个代理`)
selectedProxies.value = []
fetchProxies()
}
} catch {
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()} 格式成功啦~`)
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功`)
}
}
@@ -249,29 +260,40 @@ function handleCurrentChange(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-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.table-card:hover,
.filter-card:hover {
border-color: var(--border-light);
}
.card-title {
font-size: 18px;
font-weight: 600;
.header-icon {
margin-right: 8px;
color: var(--primary);
}
@@ -280,15 +302,28 @@ onMounted(() => {
gap: 10px;
}
.btn-icon {
font-size: 20px;
margin-right: 0;
.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: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
</style>

View File

@@ -1,35 +1,93 @@
<template>
<div class="page-container">
<PageHeader title="系统设置" icon="⚙️" />
<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">🎨 基础配置</span>
<el-button type="primary" @click="handleSave" size="large" :loading="saving">
<span class="btn-icon">💾</span>
<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="150px" class="settings-form">
<el-form-item label="管理员API Key">
<el-input
v-model="settings.api_key"
placeholder="请输入管理员API Key"
type="password"
show-password
class="setting-input"
/>
<div class="setting-hint">用于执行管理操作的API Key</div>
</el-form-item>
<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="数据库路径">
<el-input v-model="settings.db_path" placeholder="数据库文件路径" />
</el-form-item>
<el-form-item label="爬取超时">
<el-form-item label="爬取超时" prop="crawl_timeout">
<el-input-number
v-model="settings.crawl_timeout"
:min="5"
@@ -40,7 +98,18 @@
<span class="setting-suffix"></span>
</el-form-item>
<el-form-item label="验证超时">
<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"
@@ -51,16 +120,7 @@
<span class="setting-suffix"></span>
</el-form-item>
<el-form-item label="最大重试次数">
<el-input-number
v-model="settings.max_retries"
:min="0"
:max="10"
class="setting-input"
/>
</el-form-item>
<el-form-item label="默认并发数">
<el-form-item label="验证并发数" prop="default_concurrency">
<el-input-number
v-model="settings.default_concurrency"
:min="10"
@@ -70,17 +130,39 @@
/>
</el-form-item>
<el-form-item label="最低代理分数">
<el-form-item label="自动验证间隔" prop="validate_interval_minutes">
<el-input-number
v-model="settings.min_proxy_score"
:min="0"
:max="10"
:step="1"
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-form-item label="代理过期时间">
<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"
@@ -89,6 +171,7 @@
class="setting-input"
/>
<span class="setting-suffix"></span>
<span class="setting-hint">超过此时间未验证的代理将被清理</span>
</el-form-item>
</el-form>
</el-card>
@@ -96,73 +179,207 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
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 { settingsAPI, schedulerAPI } from '../api'
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({
api_key: '',
db_path: '',
crawl_timeout: 30,
validation_timeout: 10,
max_retries: 3,
default_concurrency: 50,
min_proxy_score: 5,
proxy_expiry_days: 7
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 fetch('http://localhost:8923/api/settings')
if (response.ok) {
const data = await response.json()
Object.assign(settings, data)
const response = await settingsAPI.getSettings()
if (response.code === 200) {
Object.assign(settings, response.data)
}
settings.api_key = localStorage.getItem('api_key') || ''
} catch (error) {
console.error('获取设置失败:', error)
ElMessage.error('获取设置失败')
} finally {
loading.value = false
}
}
async function fetchSchedulerStatus() {
try {
const response = await schedulerAPI.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 schedulerAPI.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 schedulerAPI.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 schedulerAPI.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 {
if (settings.api_key) {
localStorage.setItem('api_key', settings.api_key)
} else {
localStorage.removeItem('api_key')
}
const response = await settingsAPI.saveSettings(settings)
const { api_key, ...settingsToSend } = settings
const response = await fetch('http://localhost:8923/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settingsToSend)
})
if (response.ok) {
ElMessage.success('配置保存成功啦~')
if (response.code === 200) {
ElMessage.success('配置保存成功')
// 刷新调度器状态
await fetchSchedulerStatus()
} else {
ElMessage.error('配置保存失败呢~')
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-xl);
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 {
@@ -172,13 +389,49 @@ onMounted(() => {
}
.card-title {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--primary);
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: 20px;
padding: 16px;
max-width: 800px;
}
@@ -188,18 +441,29 @@ onMounted(() => {
.setting-suffix {
margin-left: 10px;
color: var(--text-secondary);
color: var(--text-muted);
font-size: 14px;
}
.setting-hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
margin-left: 10px;
color: var(--text-muted);
font-size: 13px;
}
.btn-icon {
font-size: 20px;
margin-right: 8px;
: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>

View File

@@ -5,6 +5,30 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 6173
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
}
})