重构代理池系统:简化架构并增强核心功能
后端变更: - 移除 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:
26
.env.example
26
.env.example
@@ -6,7 +6,7 @@ DB_PATH=db/proxies.sqlite
|
|||||||
|
|
||||||
# ==================== API服务配置 ====================
|
# ==================== API服务配置 ====================
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=9949
|
||||||
|
|
||||||
# ==================== 验证器配置 ====================
|
# ==================== 验证器配置 ====================
|
||||||
VALIDATOR_TIMEOUT=5
|
VALIDATOR_TIMEOUT=5
|
||||||
@@ -17,10 +17,6 @@ VALIDATOR_CONNECT_TIMEOUT=3
|
|||||||
CRAWLER_NUM_VALIDATORS=50
|
CRAWLER_NUM_VALIDATORS=50
|
||||||
CRAWLER_MAX_QUEUE_SIZE=500
|
CRAWLER_MAX_QUEUE_SIZE=500
|
||||||
|
|
||||||
# ==================== 定时任务配置 ====================
|
|
||||||
SCHEDULER_INTERVAL_MINUTES=60
|
|
||||||
SCHEDULER_ENABLED=false
|
|
||||||
|
|
||||||
# ==================== 日志配置 ====================
|
# ==================== 日志配置 ====================
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_DIR=logs
|
LOG_DIR=logs
|
||||||
@@ -34,29 +30,9 @@ SCORE_INVALID=-5
|
|||||||
SCORE_MIN=0
|
SCORE_MIN=0
|
||||||
SCORE_MAX=100
|
SCORE_MAX=100
|
||||||
|
|
||||||
# ==================== WebSocket配置 ====================
|
|
||||||
WS_PING_INTERVAL=20
|
|
||||||
WS_PING_TIMEOUT=20
|
|
||||||
|
|
||||||
# ==================== 插件配置 ====================
|
# ==================== 插件配置 ====================
|
||||||
PLUGINS_DIR=plugins
|
PLUGINS_DIR=plugins
|
||||||
|
|
||||||
# ==================== CORS配置 ====================
|
# ==================== CORS配置 ====================
|
||||||
# 允许的来源域名,用逗号分隔
|
# 允许的来源域名,用逗号分隔
|
||||||
# 开发环境示例: http://localhost:8080,http://localhost:5173
|
|
||||||
# 生产环境示例: https://yourdomain.com,https://api.yourdomain.com
|
|
||||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||||
|
|
||||||
# ==================== API Key配置 ====================
|
|
||||||
# 普通用户API Key(只读权限)
|
|
||||||
# 请修改为强随机字符串,例如: openssl rand -hex 32
|
|
||||||
API_KEY=your-api-key-here
|
|
||||||
|
|
||||||
# 管理员API Key(读写权限)
|
|
||||||
# 请修改为强随机字符串
|
|
||||||
ADMIN_API_KEY=your-admin-api-key-here
|
|
||||||
|
|
||||||
# ==================== 认证开关 ====================
|
|
||||||
# 是否启用API认证
|
|
||||||
# 开发环境可设为 false,生产环境务必设为 true
|
|
||||||
REQUIRE_AUTH=false
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -89,3 +89,8 @@ proxies.sqlite*
|
|||||||
# Test/Maintenance Scripts
|
# Test/Maintenance Scripts
|
||||||
clear_*.py
|
clear_*.py
|
||||||
test_*.py
|
test_*.py
|
||||||
|
test_results.json
|
||||||
|
test_screenshot_*.png
|
||||||
|
|
||||||
|
# Legacy/Backup
|
||||||
|
backend/
|
||||||
|
|||||||
107
README.md
107
README.md
@@ -5,22 +5,21 @@
|
|||||||
## 🌟 特性
|
## 🌟 特性
|
||||||
|
|
||||||
- 🔮 **科技风设计** - 现代化的深色科技主题
|
- 🔮 **科技风设计** - 现代化的深色科技主题
|
||||||
- 📊 **实时监控** - WebSocket 实时推送任务进度
|
- 📊 **实时监控** - 自动统计代理池状态
|
||||||
- 🎯 **智能管理** - 代理查询、筛选、排序、批量操作
|
- 🎯 **智能管理** - 代理查询、筛选、排序、批量操作
|
||||||
- 📥 **多格式导出** - 支持 CSV、TXT、JSON 格式
|
- 📥 **多格式导出** - 支持 CSV、TXT、JSON 格式
|
||||||
- ⏰ **定时任务** - 自动定期更新代理池
|
- ✅ **自动验证** - 自动验证代理可用性并评分
|
||||||
- 🚀 **高性能** - 异步爬取和验证,支持高并发
|
- 🚀 **高性能** - 异步爬取和验证,支持高并发
|
||||||
|
|
||||||
## 📦 技术栈
|
## 📦 技术栈
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
- **框架**: FastAPI (端口 8923)
|
- **框架**: FastAPI (端口 9949)
|
||||||
- **数据库**: SQLite + aiosqlite
|
- **数据库**: SQLite + aiosqlite
|
||||||
- **异步**: asyncio
|
- **异步**: asyncio
|
||||||
- **实时通信**: WebSocket
|
|
||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
- **框架**: Vue 3 + Vite (端口 6173)
|
- **框架**: Vue 3 + Vite (端口 9948)
|
||||||
- **UI库**: Element Plus
|
- **UI库**: Element Plus
|
||||||
- **状态管理**: Pinia
|
- **状态管理**: Pinia
|
||||||
- **图表**: ECharts
|
- **图表**: ECharts
|
||||||
@@ -72,32 +71,27 @@ stop.bat
|
|||||||
|
|
||||||
### 4. 访问 WebUI
|
### 4. 访问 WebUI
|
||||||
|
|
||||||
打开浏览器访问:**http://localhost:6173**
|
打开浏览器访问:**http://localhost:9948**
|
||||||
|
|
||||||
## 📁 项目结构
|
## 📁 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
ProxyPool/
|
ProxyPool/
|
||||||
├── api_server.py # FastAPI 后端服务器
|
├── api_server.py # FastAPI 后端服务器
|
||||||
├── tasks_manager.py # 任务管理器
|
├── config.py # 配置文件
|
||||||
├── main.py # 爬虫主程序
|
├── requirements.txt # Python 依赖
|
||||||
├── config.py # 配置文件
|
├── .env.example # 环境变量示例
|
||||||
├── requirements.txt # Python 依赖
|
|
||||||
├── .env.example # 环境变量示例
|
|
||||||
│
|
│
|
||||||
├── script/ # 启动脚本
|
├── script/ # 启动脚本
|
||||||
│ ├── start.bat # Windows 启动脚本
|
│ ├── start.bat # Windows 启动脚本
|
||||||
│ ├── start.ps1 # PowerShell 启动脚本
|
│ └── stop.bat # Windows 停止脚本
|
||||||
│ ├── stop.bat # Windows 停止脚本
|
|
||||||
│ └── README.md # 脚本说明文档
|
|
||||||
│
|
│
|
||||||
├── core/ # 核心模块
|
├── core/ # 核心模块
|
||||||
│ ├── crawler.py # 爬虫基类
|
│ ├── crawler.py # 爬虫基类
|
||||||
│ ├── validator.py # 代理验证器
|
│ ├── validator.py # 代理验证器
|
||||||
│ ├── sqlite.py # 数据库管理
|
│ ├── sqlite.py # 数据库管理
|
||||||
│ ├── plugin_manager.py # 插件管理器
|
│ ├── plugin_manager.py # 插件管理器
|
||||||
│ ├── log.py # 日志配置
|
│ └── log.py # 日志配置
|
||||||
│ └── auth.py # 认证模块
|
|
||||||
│
|
│
|
||||||
├── plugins/ # 代理源插件
|
├── plugins/ # 代理源插件
|
||||||
│ ├── fate0.py # Fate0 代理源
|
│ ├── fate0.py # Fate0 代理源
|
||||||
@@ -115,12 +109,9 @@ ProxyPool/
|
|||||||
│ │ ├── views/ # 页面组件
|
│ │ ├── views/ # 页面组件
|
||||||
│ │ ├── router/ # 路由配置
|
│ │ ├── router/ # 路由配置
|
||||||
│ │ ├── components/ # 通用组件
|
│ │ ├── components/ # 通用组件
|
||||||
│ │ ├── App.vue
|
|
||||||
│ │ ├── main.js
|
|
||||||
│ │ └── style.css # 全局样式
|
│ │ └── style.css # 全局样式
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── package.json
|
│ └── package.json
|
||||||
│ └── vite.config.js
|
|
||||||
│
|
│
|
||||||
└── db/ # 数据存储目录
|
└── db/ # 数据存储目录
|
||||||
└── proxies.sqlite # SQLite 数据库
|
└── proxies.sqlite # SQLite 数据库
|
||||||
@@ -151,46 +142,50 @@ POST /api/proxies
|
|||||||
GET /api/proxies/random
|
GET /api/proxies/random
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动爬虫
|
### 导出代理
|
||||||
```
|
```
|
||||||
POST /api/crawler/start
|
GET /api/proxies/export/{format}
|
||||||
|
# format: csv, txt, json
|
||||||
```
|
```
|
||||||
|
|
||||||
### 停止爬虫
|
### 删除代理
|
||||||
```
|
```
|
||||||
POST /api/crawler/stop
|
DELETE /api/proxies/{ip}/{port}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 定时任务
|
### 批量删除代理
|
||||||
```
|
```
|
||||||
POST /api/scheduler
|
POST /api/proxies/batch-delete
|
||||||
GET /api/scheduler
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### WebSocket 连接
|
### 清理无效代理
|
||||||
```
|
```
|
||||||
ws://localhost:8923/ws
|
DELETE /api/proxies/clean-invalid
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插件列表
|
||||||
|
```
|
||||||
|
GET /api/plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换插件状态
|
||||||
|
```
|
||||||
|
PUT /api/plugins/{plugin_id}/toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行插件爬取
|
||||||
|
```
|
||||||
|
POST /api/plugins/{plugin_id}/crawl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 系统设置
|
||||||
|
```
|
||||||
|
GET /api/settings
|
||||||
|
POST /api/settings
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐛 调试指南
|
## 🐛 调试指南
|
||||||
|
|
||||||
### 任务进度不显示?
|
|
||||||
|
|
||||||
1. **检查 WebSocket 连接**
|
|
||||||
- 打开浏览器控制台(F12)
|
|
||||||
- 查看 Console 标签
|
|
||||||
- 应该看到 "WebSocket连接成功啦~"
|
|
||||||
- 应该看到 "收到WebSocket消息:" 日志
|
|
||||||
|
|
||||||
2. **检查后端任务**
|
|
||||||
- 查看后端终端输出
|
|
||||||
- 确认任务正在运行
|
|
||||||
- 查看是否有错误日志
|
|
||||||
|
|
||||||
3. **检查插件可用性**
|
|
||||||
- 确保 `plugins/` 目录下有插件文件
|
|
||||||
- 插件能正常抓取代理
|
|
||||||
|
|
||||||
### 数据不更新?
|
### 数据不更新?
|
||||||
|
|
||||||
1. **检查数据库**
|
1. **检查数据库**
|
||||||
@@ -200,10 +195,10 @@ ws://localhost:8923/ws
|
|||||||
2. **手动测试 API**
|
2. **手动测试 API**
|
||||||
```bash
|
```bash
|
||||||
# 获取统计信息
|
# 获取统计信息
|
||||||
curl http://localhost:8923/api/stats
|
curl http://localhost:9949/api/stats
|
||||||
|
|
||||||
# 获取代理列表
|
# 获取代理列表
|
||||||
curl -X POST http://localhost:8923/api/proxies \
|
curl -X POST http://localhost:9949/api/proxies \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"page": 1, "page_size": 20}'
|
-d '{"page": 1, "page_size": 20}'
|
||||||
```
|
```
|
||||||
@@ -215,19 +210,19 @@ ws://localhost:8923/ws
|
|||||||
|
|
||||||
## 📝 配置说明
|
## 📝 配置说明
|
||||||
|
|
||||||
### 爬虫配置
|
### 代理验证配置
|
||||||
- **最大并发数**: 10-500,默认 200
|
|
||||||
- **验证超时**: 3-30秒,默认 5秒
|
- **验证超时**: 3-30秒,默认 5秒
|
||||||
- **验证线程数**: 10-200,默认 50
|
- **验证并发数**: 10-200,默认 50
|
||||||
|
|
||||||
### 定时任务
|
### 评分机制
|
||||||
- **执行间隔**: 10-1440分钟,默认 60分钟
|
- **验证成功**: +10 分
|
||||||
- **自动清理**: 可选,清理无效代理
|
- **验证失败**: -5 分
|
||||||
|
- **分数为 0**: 自动删除
|
||||||
|
|
||||||
## 🔧 常见问题
|
## 🔧 常见问题
|
||||||
|
|
||||||
### Q: 启动后端口被占用?
|
### Q: 启动后端口被占用?
|
||||||
A: 修改 `api_server.py` 最后一行的端口号(默认8923)或 `frontend/vite.config.js` 中的端口号(默认6173)
|
A: 修改 `config.py` 中的端口号(默认9949)或 `frontend/vite.config.js` 中的端口号(默认9948)
|
||||||
|
|
||||||
### Q: 爬虫无法抓取代理?
|
### Q: 爬虫无法抓取代理?
|
||||||
A: 检查网络连接,确保能访问目标网站,或尝试更换代理源插件
|
A: 检查网络连接,确保能访问目标网站,或尝试更换代理源插件
|
||||||
@@ -236,7 +231,7 @@ A: 检查网络连接,确保能访问目标网站,或尝试更换代理源
|
|||||||
A: 增加验证超时时间,或减少并发验证数量
|
A: 增加验证超时时间,或减少并发验证数量
|
||||||
|
|
||||||
### Q: 数据库文件在哪里?
|
### Q: 数据库文件在哪里?
|
||||||
A: 默认在 `db/proxies.sqlite`,可在 `core/sqlite.py` 中修改 `db_path`
|
A: 默认在 `db/proxies.sqlite`,可在 `config.py` 中修改 `DB_PATH`
|
||||||
|
|
||||||
### Q: 如何清空数据库?
|
### Q: 如何清空数据库?
|
||||||
A: 运行命令 `python -c "from core.sqlite import SQLiteManager; import asyncio; asyncio.run(SQLiteManager().clear_all())"`
|
A: 运行命令 `python -c "from core.sqlite import SQLiteManager; import asyncio; asyncio.run(SQLiteManager().clear_all())"`
|
||||||
|
|||||||
89
README_SOCKS.md
Normal file
89
README_SOCKS.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# SOCKS 代理支持说明
|
||||||
|
|
||||||
|
## 更新内容
|
||||||
|
|
||||||
|
已成功为代理池系统添加 SOCKS4/SOCKS5 代理验证支持!
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 1. 新增依赖
|
||||||
|
```
|
||||||
|
aiohttp-socks==0.9.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 验证器升级 (`core/validator.py`)
|
||||||
|
- 新增 `ProxyValidator` 类,完整支持 HTTP/HTTPS/SOCKS4/SOCKS5
|
||||||
|
- SOCKS 代理使用 `aiohttp_socks.ProxyConnector` 进行验证
|
||||||
|
- 支持远程 DNS 解析 (rdns=True),避免 DNS 泄漏
|
||||||
|
|
||||||
|
### 3. 协议识别
|
||||||
|
以下插件已更新支持 SOCKS 协议:
|
||||||
|
|
||||||
|
| 插件 | 支持协议 |
|
||||||
|
|-----|---------|
|
||||||
|
| Fate0聚合源 | HTTP, HTTPS, SOCKS4, SOCKS5 |
|
||||||
|
| SpeedX代理源 | HTTP, SOCKS4, SOCKS5 |
|
||||||
|
| ProxyListDownload | HTTP, HTTPS, SOCKS4, SOCKS5 |
|
||||||
|
| 快代理 | HTTP, HTTPS |
|
||||||
|
| IP3366 | HTTP, HTTPS |
|
||||||
|
| 89免费代理 | HTTP |
|
||||||
|
| 云代理 | HTTP, HTTPS |
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 启动后端
|
||||||
|
python api_server.py
|
||||||
|
|
||||||
|
# 启动前端
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 抓取 SOCKS 代理
|
||||||
|
1. 打开 WebUI (http://localhost:9948)
|
||||||
|
2. 进入"插件管理"页面
|
||||||
|
3. 点击 SpeedX 或 ProxyListDownload 插件的"立即爬取"
|
||||||
|
4. 系统自动识别 SOCKS 代理并进行验证
|
||||||
|
|
||||||
|
### 查看 SOCKS 代理
|
||||||
|
1. 进入"代理列表"页面
|
||||||
|
2. 使用协议筛选器选择 SOCKS4 或 SOCKS5
|
||||||
|
3. 查看验证结果和延迟
|
||||||
|
|
||||||
|
## 验证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------+ +------------------+ +-----------------+
|
||||||
|
| 插件爬取 | --> | 识别协议类型 | --> | SOCKS验证器 |
|
||||||
|
+-------------+ +------------------+ +-----------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-------------+ +------------------+ +-----------------+
|
||||||
|
| 存储结果 | <-- | 评分更新 | <-- | 延迟测试 |
|
||||||
|
+-------------+ +------------------+ +-----------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## SOCKS 验证特点
|
||||||
|
|
||||||
|
1. **连接器类型**: 使用 `ProxyConnector` 替代 `TCPConnector`
|
||||||
|
2. **DNS 解析**: 远程解析避免泄漏真实 IP
|
||||||
|
3. **协议区分**: 明确区分 SOCKS4 和 SOCKS5
|
||||||
|
4. **统一接口**: 与 HTTP/HTTPS 代理使用相同的验证接口
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
运行测试脚本验证 SOCKS 支持:
|
||||||
|
```bash
|
||||||
|
python test_socks_validator.py
|
||||||
|
python test_plugins_socks.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. SOCKS 代理验证比 HTTP 代理稍慢,因为有额外的握手过程
|
||||||
|
2. 部分 SOCKS 代理可能只支持 TCP 而不支持 UDP
|
||||||
|
3. SOCKS5 支持认证,当前版本使用无认证模式
|
||||||
537
api_server.py
537
api_server.py
@@ -1,34 +1,92 @@
|
|||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header, Request, status
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from pydantic import BaseModel, Field, field_validator, ValidationError
|
from pydantic import BaseModel, Field, field_validator, ValidationError
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
|
||||||
import csv
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from core.sqlite import SQLiteManager
|
from core.sqlite import SQLiteManager
|
||||||
from core.validator import ProxyValidator
|
|
||||||
from core.plugin_manager import PluginManager
|
from core.plugin_manager import PluginManager
|
||||||
from tasks_manager import TasksManager, ScheduledTasks
|
from core.scheduler import ValidationScheduler
|
||||||
from core.log import logger
|
from core.log import logger
|
||||||
from config import Config
|
from config import config
|
||||||
from core.auth import verify_api_key, require_admin, PermissionLevel
|
|
||||||
|
# 全局调度器实例
|
||||||
|
scheduler = ValidationScheduler()
|
||||||
|
|
||||||
|
# 设置文件路径
|
||||||
|
SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'settings.json')
|
||||||
|
|
||||||
|
# 默认设置
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"crawl_timeout": 30,
|
||||||
|
"validation_timeout": config.VALIDATOR_TIMEOUT,
|
||||||
|
"max_retries": 3,
|
||||||
|
"default_concurrency": config.VALIDATOR_MAX_CONCURRENCY,
|
||||||
|
"min_proxy_score": config.SCORE_MIN,
|
||||||
|
"proxy_expiry_days": 7,
|
||||||
|
"auto_validate": True,
|
||||||
|
"validate_interval_minutes": 30
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings():
|
||||||
|
"""从文件加载设置"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(SETTINGS_FILE):
|
||||||
|
with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
||||||
|
saved_settings = json.load(f)
|
||||||
|
# 合并默认设置和保存的设置
|
||||||
|
settings = DEFAULT_SETTINGS.copy()
|
||||||
|
settings.update(saved_settings)
|
||||||
|
return settings
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"加载设置失败: {e}")
|
||||||
|
return DEFAULT_SETTINGS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings_to_file(settings: dict):
|
||||||
|
"""保存设置到文件"""
|
||||||
|
try:
|
||||||
|
# 确保目录存在
|
||||||
|
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
|
||||||
|
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(settings, f, ensure_ascii=False, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"保存设置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期管理"""
|
"""应用生命周期管理"""
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
await db.init_db()
|
await db.init_db()
|
||||||
logger.info("API服务器启动啦~")
|
|
||||||
|
# 加载设置并应用到调度器
|
||||||
|
settings = load_settings()
|
||||||
|
scheduler.interval_minutes = settings.get('validate_interval_minutes', 30)
|
||||||
|
|
||||||
|
# 如果启用了自动验证,启动调度器
|
||||||
|
if settings.get('auto_validate', True):
|
||||||
|
await scheduler.start()
|
||||||
|
|
||||||
|
logger.info("API服务器启动")
|
||||||
yield
|
yield
|
||||||
logger.info("API服务器关闭啦~")
|
|
||||||
|
# 关闭调度器
|
||||||
|
await scheduler.stop()
|
||||||
|
logger.info("API服务器关闭")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="代理池API", version="1.3.0", lifespan=lifespan)
|
||||||
|
|
||||||
app = FastAPI(title="代理池API", version="1.1.0", lifespan=lifespan)
|
|
||||||
|
|
||||||
def format_datetime(datetime_str: str) -> str:
|
def format_datetime(datetime_str: str) -> str:
|
||||||
"""将数据库时间格式统一转换为ISO 8601格式"""
|
"""将数据库时间格式统一转换为ISO 8601格式"""
|
||||||
@@ -44,14 +102,16 @@ def format_datetime(datetime_str: str) -> str:
|
|||||||
|
|
||||||
return datetime_str
|
return datetime_str
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValidationError)
|
@app.exception_handler(ValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: ValidationError):
|
async def validation_exception_handler(request: Request, exc: ValidationError):
|
||||||
logger.error(f"参数验证失败: {exc}")
|
logger.error(f"参数验证失败: {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
content={"code": 422, "message": "参数验证失败呢~", "data": exc.errors()}
|
content={"code": 422, "message": "参数验证失败", "data": exc.errors()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.error(f"HTTP异常: {exc.status_code} - {exc.detail}")
|
logger.error(f"HTTP异常: {exc.status_code} - {exc.detail}")
|
||||||
@@ -60,14 +120,16 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||||||
content={"code": exc.status_code, "message": exc.detail, "data": None}
|
content={"code": exc.status_code, "message": exc.detail, "data": None}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def general_exception_handler(request: Request, exc: Exception):
|
async def general_exception_handler(request: Request, exc: Exception):
|
||||||
logger.error(f"未处理的异常: {exc}", exc_info=True)
|
logger.error(f"未处理的异常: {exc}", exc_info=True)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content={"code": 500, "message": "服务器内部错误呢~", "data": None}
|
content={"code": 500, "message": "服务器内部错误", "data": None}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -76,38 +138,8 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks_manager = TasksManager()
|
|
||||||
scheduled_tasks = ScheduledTasks(tasks_manager)
|
|
||||||
plugin_manager = PluginManager()
|
plugin_manager = PluginManager()
|
||||||
active_websockets = set()
|
|
||||||
websockets_lock = asyncio.Lock()
|
|
||||||
broadcast_semaphore = asyncio.Semaphore(100)
|
|
||||||
|
|
||||||
def optional_auth():
|
|
||||||
if Config.REQUIRE_AUTH:
|
|
||||||
return Depends(verify_api_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def broadcast_message(message: dict):
|
|
||||||
"""向所有WebSocket客户端广播消息(使用信号量限制并发)"""
|
|
||||||
async with websockets_lock:
|
|
||||||
websockets_to_remove = []
|
|
||||||
|
|
||||||
async def send_to_websocket(ws):
|
|
||||||
async with broadcast_semaphore:
|
|
||||||
try:
|
|
||||||
await ws.send_json(message)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发送WebSocket消息失败: {e}")
|
|
||||||
websockets_to_remove.append(ws)
|
|
||||||
|
|
||||||
tasks = [send_to_websocket(ws) for ws in active_websockets]
|
|
||||||
|
|
||||||
if tasks:
|
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
for ws in websockets_to_remove:
|
|
||||||
active_websockets.discard(ws)
|
|
||||||
|
|
||||||
class ProxyRequest(BaseModel):
|
class ProxyRequest(BaseModel):
|
||||||
page: int = Field(default=1, ge=1, description="页码,必须大于等于1")
|
page: int = Field(default=1, ge=1, description="页码,必须大于等于1")
|
||||||
@@ -139,6 +171,7 @@ class ProxyRequest(BaseModel):
|
|||||||
raise ValueError('排序方式必须是 ASC 或 DESC')
|
raise ValueError('排序方式必须是 ASC 或 DESC')
|
||||||
return v.upper()
|
return v.upper()
|
||||||
|
|
||||||
|
|
||||||
class ProxyDeleteItem(BaseModel):
|
class ProxyDeleteItem(BaseModel):
|
||||||
ip: str
|
ip: str
|
||||||
port: int
|
port: int
|
||||||
@@ -150,6 +183,7 @@ class ProxyDeleteItem(BaseModel):
|
|||||||
raise ValueError('端口号必须在1-65535范围内')
|
raise ValueError('端口号必须在1-65535范围内')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class DeleteProxiesRequest(BaseModel):
|
class DeleteProxiesRequest(BaseModel):
|
||||||
proxies: List[ProxyDeleteItem]
|
proxies: List[ProxyDeleteItem]
|
||||||
|
|
||||||
@@ -160,16 +194,11 @@ class DeleteProxiesRequest(BaseModel):
|
|||||||
raise ValueError('单次最多删除1000个代理')
|
raise ValueError('单次最多删除1000个代理')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
class CrawlerRequest(BaseModel):
|
|
||||||
num_validators: int = 50
|
|
||||||
|
|
||||||
class ScheduleRequest(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
interval_minutes: int = 60
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "欢迎使用代理池API~", "status": "running", "data": None}
|
return {"message": "欢迎使用代理池API", "status": "running", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
@@ -180,7 +209,8 @@ async def health_check():
|
|||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
"version": "1.0.0"
|
"scheduler": "running" if scheduler.running else "stopped",
|
||||||
|
"version": "1.3.0"
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"健康检查失败: {e}")
|
logger.error(f"健康检查失败: {e}")
|
||||||
@@ -191,20 +221,23 @@ async def health_check():
|
|||||||
"error": str(e)
|
"error": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats")
|
@app.get("/api/stats")
|
||||||
async def get_stats(_permission: str = optional_auth()):
|
async def get_stats():
|
||||||
try:
|
try:
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
stats = await db.get_stats()
|
stats = await db.get_stats()
|
||||||
today_new = await db.get_today_new_count()
|
today_new = await db.get_today_new_count()
|
||||||
stats['today_new'] = today_new
|
stats['today_new'] = today_new
|
||||||
return {"code": 200, "message": "获取统计信息成功啦~", "data": stats}
|
stats['scheduler_running'] = scheduler.running
|
||||||
|
return {"code": 200, "message": "获取统计信息成功", "data": stats}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取统计信息失败: {e}")
|
logger.error(f"获取统计信息失败: {e}")
|
||||||
return {"code": 500, "message": "获取统计信息失败呢~", "data": None}
|
return {"code": 500, "message": "获取统计信息失败", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/proxies")
|
@app.post("/api/proxies")
|
||||||
async def get_proxies(request: ProxyRequest, _permission: str = optional_auth()):
|
async def get_proxies(request: ProxyRequest):
|
||||||
try:
|
try:
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
proxies = await db.get_proxies_paginated(
|
proxies = await db.get_proxies_paginated(
|
||||||
@@ -234,7 +267,7 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth())
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": "获取代理列表成功啦~",
|
"message": "获取代理列表成功",
|
||||||
"data": {
|
"data": {
|
||||||
"list": proxy_list,
|
"list": proxy_list,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -244,16 +277,17 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth())
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取代理列表失败: {e}")
|
logger.error(f"获取代理列表失败: {e}")
|
||||||
return {"code": 500, "message": "获取代理列表失败呢~", "data": None}
|
return {"code": 500, "message": "获取代理列表失败", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxies/random")
|
@app.get("/api/proxies/random")
|
||||||
async def get_random_proxy(_permission: str = optional_auth()):
|
async def get_random_proxy():
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
proxy = await db.get_random_proxy()
|
proxy = await db.get_random_proxy()
|
||||||
if proxy:
|
if proxy:
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": "获取随机代理成功啦~",
|
"message": "获取随机代理成功",
|
||||||
"data": {
|
"data": {
|
||||||
"ip": proxy[0],
|
"ip": proxy[0],
|
||||||
"port": proxy[1],
|
"port": proxy[1],
|
||||||
@@ -262,18 +296,19 @@ async def get_random_proxy(_permission: str = optional_auth()):
|
|||||||
"last_check": format_datetime(proxy[4])
|
"last_check": format_datetime(proxy[4])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {"code": 404, "message": "没有找到可用的代理呢~", "data": None}
|
return {"code": 404, "message": "没有找到可用的代理", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxies/export/{format}")
|
@app.get("/api/proxies/export/{format}")
|
||||||
async def export_proxies(format: str, protocol: Optional[str] = None, _permission: str = optional_auth(), limit: int = 10000):
|
async def export_proxies(format: str, protocol: Optional[str] = None, limit: int = 10000):
|
||||||
try:
|
try:
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
|
|
||||||
if format not in ['csv', 'txt', 'json']:
|
if format not in ['csv', 'txt', 'json']:
|
||||||
raise HTTPException(status_code=400, detail="不支持的导出格式呢~")
|
raise HTTPException(status_code=400, detail="不支持的导出格式")
|
||||||
|
|
||||||
if limit > 100000:
|
if limit > 100000:
|
||||||
raise HTTPException(status_code=400, detail="导出数量不能超过100000条呢~")
|
raise HTTPException(status_code=400, detail="导出数量不能超过100000条")
|
||||||
|
|
||||||
async def generate_csv():
|
async def generate_csv():
|
||||||
proxies = await db.get_all_proxies()
|
proxies = await db.get_all_proxies()
|
||||||
@@ -342,16 +377,17 @@ async def export_proxies(format: str, protocol: Optional[str] = None, _permissio
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"导出代理失败: {e}")
|
logger.error(f"导出代理失败: {e}")
|
||||||
raise HTTPException(status_code=500, detail="导出代理失败呢~")
|
raise HTTPException(status_code=500, detail="导出代理失败")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxies/{ip}/{port}")
|
@app.get("/api/proxies/{ip}/{port}")
|
||||||
async def get_proxy_detail(ip: str, port: int, _permission: str = optional_auth()):
|
async def get_proxy_detail(ip: str, port: int):
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
proxy = await db.get_proxy_detail(ip, port)
|
proxy = await db.get_proxy_detail(ip, port)
|
||||||
if proxy:
|
if proxy:
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": "获取代理详情成功啦~",
|
"message": "获取代理详情成功",
|
||||||
"data": {
|
"data": {
|
||||||
"ip": proxy[0],
|
"ip": proxy[0],
|
||||||
"port": proxy[1],
|
"port": proxy[1],
|
||||||
@@ -360,196 +396,303 @@ async def get_proxy_detail(ip: str, port: int, _permission: str = optional_auth(
|
|||||||
"last_check": format_datetime(proxy[4])
|
"last_check": format_datetime(proxy[4])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
raise HTTPException(status_code=404, detail="代理不存在呢~")
|
raise HTTPException(status_code=404, detail="代理不存在")
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/proxies/{ip}/{port}")
|
@app.delete("/api/proxies/{ip}/{port}")
|
||||||
async def delete_proxy(ip: str, port: int, _permission: str = Depends(require_admin)):
|
async def delete_proxy(ip: str, port: int):
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
await db.delete_proxy(ip, port)
|
await db.delete_proxy(ip, port)
|
||||||
return {"code": 200, "message": "删除代理成功啦~", "data": None}
|
return {"code": 200, "message": "删除代理成功", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/proxies/batch-delete")
|
@app.post("/api/proxies/batch-delete")
|
||||||
async def batch_delete_proxies(request: DeleteProxiesRequest, _permission: str = Depends(require_admin)):
|
async def batch_delete_proxies(request: DeleteProxiesRequest):
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
proxy_tuples = [(item.ip, item.port) for item in request.proxies]
|
proxy_tuples = [(item.ip, item.port) for item in request.proxies]
|
||||||
deleted_count = await db.batch_delete_proxies(proxy_tuples)
|
deleted_count = await db.batch_delete_proxies(proxy_tuples)
|
||||||
return {"code": 200, "message": f"批量删除 {deleted_count} 个代理成功啦~", "data": {"deleted_count": deleted_count}}
|
return {"code": 200, "message": f"批量删除 {deleted_count} 个代理成功", "data": {"deleted_count": deleted_count}}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/proxies/clean-invalid")
|
@app.delete("/api/proxies/clean-invalid")
|
||||||
async def clean_invalid_proxies(_permission: str = Depends(require_admin)):
|
async def clean_invalid_proxies():
|
||||||
db = SQLiteManager()
|
db = SQLiteManager()
|
||||||
deleted_count = await db.clean_invalid_proxies()
|
deleted_count = await db.clean_invalid_proxies()
|
||||||
return {"code": 200, "message": f"清理了 {deleted_count} 个无效代理啦~", "data": {"deleted_count": deleted_count}}
|
return {"code": 200, "message": f"清理了 {deleted_count} 个无效代理", "data": {"deleted_count": deleted_count}}
|
||||||
|
|
||||||
@app.post("/api/crawler/start")
|
|
||||||
async def start_crawler(request: CrawlerRequest, _permission: str = Depends(require_admin)):
|
|
||||||
try:
|
|
||||||
if tasks_manager.is_task_running():
|
|
||||||
return {"code": 400, "message": "任务正在运行中呢~"}
|
|
||||||
|
|
||||||
async def progress_callback(data):
|
|
||||||
await broadcast_message({"type": "progress", "data": data})
|
|
||||||
|
|
||||||
async def status_callback(data):
|
|
||||||
await broadcast_message({"type": "status", "data": data})
|
|
||||||
|
|
||||||
tasks_manager.set_callbacks(progress_callback, status_callback)
|
|
||||||
|
|
||||||
db = SQLiteManager()
|
|
||||||
asyncio.create_task(tasks_manager.start_task(db, request.num_validators))
|
|
||||||
|
|
||||||
return {"code": 200, "message": "爬虫任务开始啦~", "data": None}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"启动爬虫失败: {e}")
|
|
||||||
return {"code": 500, "message": "启动爬虫失败呢~", "data": None}
|
|
||||||
|
|
||||||
@app.post("/api/crawler/stop")
|
|
||||||
async def stop_crawler(_permission: str = Depends(require_admin)):
|
|
||||||
if not tasks_manager.is_task_running():
|
|
||||||
return {"code": 400, "message": "没有运行中的任务呢~", "data": None}
|
|
||||||
|
|
||||||
await tasks_manager.stop_task()
|
|
||||||
return {"code": 200, "message": "爬虫任务停止啦~", "data": None}
|
|
||||||
|
|
||||||
@app.get("/api/crawler/status")
|
|
||||||
async def get_crawler_status(_permission: str = optional_auth()):
|
|
||||||
return {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取爬虫状态成功啦~",
|
|
||||||
"data": {
|
|
||||||
"running": tasks_manager.is_task_running(),
|
|
||||||
"stats": tasks_manager.get_stats()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/api/scheduler")
|
|
||||||
async def set_scheduler(request: ScheduleRequest, _permission: str = Depends(require_admin)):
|
|
||||||
if request.enabled:
|
|
||||||
scheduled_tasks.start_scheduled(request.interval_minutes)
|
|
||||||
return {"code": 200, "message": f"定时任务已启动,间隔 {request.interval_minutes} 分钟~", "data": None}
|
|
||||||
else:
|
|
||||||
scheduled_tasks.stop_scheduled()
|
|
||||||
return {"code": 200, "message": "定时任务已停止~", "data": None}
|
|
||||||
|
|
||||||
@app.get("/api/scheduler")
|
|
||||||
async def get_scheduler_status(_permission: str = optional_auth()):
|
|
||||||
return {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取定时任务状态成功啦~",
|
|
||||||
"data": {
|
|
||||||
"enabled": scheduled_tasks.is_scheduled,
|
|
||||||
"interval_minutes": scheduled_tasks.interval_minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
|
||||||
async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = None):
|
|
||||||
if Config.REQUIRE_AUTH:
|
|
||||||
if not token:
|
|
||||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="缺少认证token")
|
|
||||||
logger.warning("WebSocket连接被拒绝:缺少token")
|
|
||||||
return
|
|
||||||
|
|
||||||
if token != Config.API_KEY and token != Config.ADMIN_API_KEY:
|
|
||||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无效的token")
|
|
||||||
logger.warning(f"WebSocket连接被拒绝:无效的token {token[:8]}...")
|
|
||||||
return
|
|
||||||
|
|
||||||
permission_level = PermissionLevel.ADMIN if token == Config.ADMIN_API_KEY else PermissionLevel.READ_ONLY
|
|
||||||
logger.info(f"WebSocket连接成功,权限级别: {permission_level}")
|
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
async with websockets_lock:
|
|
||||||
active_websockets.add(websocket)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "status",
|
|
||||||
"data": {
|
|
||||||
"status": "connected",
|
|
||||||
"message": "WebSocket连接成功啦~",
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
async with websockets_lock:
|
|
||||||
active_websockets.discard(websocket)
|
|
||||||
logger.info("WebSocket断开连接")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"WebSocket错误: {e}")
|
|
||||||
async with websockets_lock:
|
|
||||||
active_websockets.discard(websocket)
|
|
||||||
|
|
||||||
@app.get("/api/plugins")
|
@app.get("/api/plugins")
|
||||||
async def get_plugins(_permission: str = optional_auth()):
|
async def get_plugins():
|
||||||
try:
|
try:
|
||||||
plugins_info = plugin_manager.get_all_plugin_info()
|
plugins_info = plugin_manager.get_all_plugin_info()
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": "获取插件列表成功啦~",
|
"message": "获取插件列表成功",
|
||||||
"data": {
|
"data": {
|
||||||
"plugins": plugins_info
|
"plugins": plugins_info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取插件列表失败: {e}")
|
logger.error(f"获取插件列表失败: {e}")
|
||||||
return {"code": 500, "message": "获取插件列表失败呢~", "data": None}
|
return {"code": 500, "message": "获取插件列表失败", "data": None}
|
||||||
|
|
||||||
|
|
||||||
class PluginToggleRequest(BaseModel):
|
class PluginToggleRequest(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/plugins/{plugin_id}/toggle")
|
@app.put("/api/plugins/{plugin_id}/toggle")
|
||||||
async def toggle_plugin(plugin_id: str, request: PluginToggleRequest, _permission: str = Depends(require_admin)):
|
async def toggle_plugin(plugin_id: str, request: PluginToggleRequest):
|
||||||
try:
|
try:
|
||||||
success = plugin_manager.toggle_plugin(plugin_id, request.enabled)
|
success = plugin_manager.toggle_plugin(plugin_id, request.enabled)
|
||||||
if success:
|
if success:
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": f"插件 {plugin_id} 已{'启用' if request.enabled else '禁用'}啦~",
|
"message": f"插件 {plugin_id} 已{'启用' if request.enabled else '禁用'}",
|
||||||
"data": {
|
"data": {
|
||||||
"plugin_id": plugin_id,
|
"plugin_id": plugin_id,
|
||||||
"enabled": request.enabled
|
"enabled": request.enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {"code": 404, "message": "插件不存在呢~", "data": None}
|
return {"code": 404, "message": "插件不存在", "data": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"切换插件状态失败: {e}")
|
logger.error(f"切换插件状态失败: {e}")
|
||||||
return {"code": 500, "message": "切换插件状态失败呢~", "data": None}
|
return {"code": 500, "message": "切换插件状态失败", "data": None}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/plugins/{plugin_id}/crawl")
|
@app.post("/api/plugins/{plugin_id}/crawl")
|
||||||
async def crawl_plugin(plugin_id: str, _permission: str = Depends(require_admin)):
|
async def crawl_plugin(plugin_id: str):
|
||||||
try:
|
try:
|
||||||
async def progress_callback(data):
|
# 1. 执行爬取
|
||||||
await broadcast_message({"type": "progress", "data": data})
|
|
||||||
|
|
||||||
async def status_callback(data):
|
|
||||||
await broadcast_message({"type": "status", "data": data})
|
|
||||||
|
|
||||||
tasks_manager.set_callbacks(progress_callback, status_callback)
|
|
||||||
|
|
||||||
db = SQLiteManager()
|
|
||||||
results = await plugin_manager.run_plugin(plugin_id)
|
results = await plugin_manager.run_plugin(plugin_id)
|
||||||
|
|
||||||
for ip, port, protocol in results:
|
if not results:
|
||||||
await db.insert_proxy(ip, port, protocol)
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": f"插件 {plugin_id} 爬取完成,未获取到代理",
|
||||||
|
"data": {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"proxy_count": 0,
|
||||||
|
"valid_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"插件 {plugin_id} 爬取完成,获取 {len(results)} 个代理,开始验证...")
|
||||||
|
|
||||||
|
# 2. 验证新抓取的代理
|
||||||
|
valid_proxies, invalid_proxies = await scheduler.validate_proxies_batch(results)
|
||||||
|
|
||||||
|
# 3. 只将有效代理存入数据库
|
||||||
|
db = SQLiteManager()
|
||||||
|
inserted_count = 0
|
||||||
|
for ip, port, protocol in valid_proxies:
|
||||||
|
success = await db.insert_proxy(ip, port, protocol, score=config.SCORE_VALID)
|
||||||
|
if success:
|
||||||
|
inserted_count += 1
|
||||||
|
|
||||||
|
logger.info(f"插件 {plugin_id} 处理完成: 有效 {inserted_count}, 无效 {len(invalid_proxies)}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": f"插件 {plugin_id} 开始爬取啦~",
|
"message": f"插件 {plugin_id} 爬取并验证完成",
|
||||||
"data": {
|
"data": {
|
||||||
"plugin_id": plugin_id,
|
"plugin_id": plugin_id,
|
||||||
"proxy_count": len(results)
|
"proxy_count": len(results),
|
||||||
|
"valid_count": inserted_count,
|
||||||
|
"invalid_count": len(invalid_proxies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"插件爬取失败: {e}")
|
logger.error(f"插件爬取失败: {e}")
|
||||||
return {"code": 500, "message": "插件爬取失败呢~", "data": None}
|
return {"code": 500, "message": f"插件爬取失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/plugins/crawl-all")
|
||||||
|
async def crawl_all_plugins():
|
||||||
|
"""运行所有插件并验证"""
|
||||||
|
try:
|
||||||
|
all_results = []
|
||||||
|
all_valid = []
|
||||||
|
all_invalid = []
|
||||||
|
|
||||||
|
for plugin in plugin_manager.plugins:
|
||||||
|
if not plugin.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await plugin_manager.run_plugin(plugin.name)
|
||||||
|
if results:
|
||||||
|
all_results.extend(results)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件 {plugin.name} 执行失败: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if all_results:
|
||||||
|
# 去重
|
||||||
|
unique_proxies = list(set(all_results))
|
||||||
|
logger.info(f"所有插件爬取完成,共 {len(unique_proxies)} 个唯一代理,开始验证...")
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
valid_proxies, invalid_proxies = await scheduler.validate_proxies_batch(unique_proxies)
|
||||||
|
|
||||||
|
# 保存有效代理
|
||||||
|
db = SQLiteManager()
|
||||||
|
inserted_count = 0
|
||||||
|
for ip, port, protocol in valid_proxies:
|
||||||
|
success = await db.insert_proxy(ip, port, protocol, score=config.SCORE_VALID)
|
||||||
|
if success:
|
||||||
|
inserted_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "所有插件爬取并验证完成",
|
||||||
|
"data": {
|
||||||
|
"total_crawled": len(unique_proxies),
|
||||||
|
"valid_count": inserted_count,
|
||||||
|
"invalid_count": len(invalid_proxies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "所有插件爬取完成,未获取到代理",
|
||||||
|
"data": {
|
||||||
|
"total_crawled": 0,
|
||||||
|
"valid_count": 0,
|
||||||
|
"invalid_count": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"批量爬取失败: {e}")
|
||||||
|
return {"code": 500, "message": f"批量爬取失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
# 验证调度器控制
|
||||||
|
@app.post("/api/scheduler/start")
|
||||||
|
async def start_scheduler():
|
||||||
|
"""启动验证调度器"""
|
||||||
|
try:
|
||||||
|
if scheduler.running:
|
||||||
|
return {"code": 200, "message": "验证调度器已在运行", "data": {"running": True}}
|
||||||
|
|
||||||
|
await scheduler.start()
|
||||||
|
|
||||||
|
# 更新设置
|
||||||
|
settings = load_settings()
|
||||||
|
settings['auto_validate'] = True
|
||||||
|
save_settings_to_file(settings)
|
||||||
|
|
||||||
|
return {"code": 200, "message": "验证调度器已启动", "data": {"running": True}}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动调度器失败: {e}")
|
||||||
|
return {"code": 500, "message": f"启动调度器失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scheduler/stop")
|
||||||
|
async def stop_scheduler():
|
||||||
|
"""停止验证调度器"""
|
||||||
|
try:
|
||||||
|
if not scheduler.running:
|
||||||
|
return {"code": 200, "message": "验证调度器未运行", "data": {"running": False}}
|
||||||
|
|
||||||
|
await scheduler.stop()
|
||||||
|
|
||||||
|
# 更新设置
|
||||||
|
settings = load_settings()
|
||||||
|
settings['auto_validate'] = False
|
||||||
|
save_settings_to_file(settings)
|
||||||
|
|
||||||
|
return {"code": 200, "message": "验证调度器已停止", "data": {"running": False}}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"停止调度器失败: {e}")
|
||||||
|
return {"code": 500, "message": f"停止调度器失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scheduler/validate-now")
|
||||||
|
async def validate_now():
|
||||||
|
"""立即执行一次全量验证"""
|
||||||
|
try:
|
||||||
|
# 在后台运行验证,不阻塞响应
|
||||||
|
asyncio.create_task(scheduler.validate_all_proxies())
|
||||||
|
return {"code": 200, "message": "已开始全量验证", "data": {"started": True}}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动验证失败: {e}")
|
||||||
|
return {"code": 500, "message": f"启动验证失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/scheduler/status")
|
||||||
|
async def get_scheduler_status():
|
||||||
|
"""获取调度器状态"""
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "获取状态成功",
|
||||||
|
"data": {
|
||||||
|
"running": scheduler.running,
|
||||||
|
"interval_minutes": scheduler.interval_minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 设置管理
|
||||||
|
class SettingsRequest(BaseModel):
|
||||||
|
crawl_timeout: int = Field(default=30, ge=5, le=120)
|
||||||
|
validation_timeout: int = Field(default=10, ge=3, le=60)
|
||||||
|
max_retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
default_concurrency: int = Field(default=50, ge=10, le=200)
|
||||||
|
min_proxy_score: int = Field(default=0, ge=0, le=100)
|
||||||
|
proxy_expiry_days: int = Field(default=7, ge=1, le=30)
|
||||||
|
auto_validate: bool = True
|
||||||
|
validate_interval_minutes: int = Field(default=30, ge=5, le=1440)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
async def get_settings():
|
||||||
|
"""获取系统设置"""
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
return {"code": 200, "message": "获取设置成功", "data": settings}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取设置失败: {e}")
|
||||||
|
return {"code": 500, "message": "获取设置失败", "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
async def save_settings(request: SettingsRequest):
|
||||||
|
"""保存系统设置"""
|
||||||
|
try:
|
||||||
|
settings = {
|
||||||
|
"crawl_timeout": request.crawl_timeout,
|
||||||
|
"validation_timeout": request.validation_timeout,
|
||||||
|
"max_retries": request.max_retries,
|
||||||
|
"default_concurrency": request.default_concurrency,
|
||||||
|
"min_proxy_score": request.min_proxy_score,
|
||||||
|
"proxy_expiry_days": request.proxy_expiry_days,
|
||||||
|
"auto_validate": request.auto_validate,
|
||||||
|
"validate_interval_minutes": request.validate_interval_minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存到文件
|
||||||
|
if save_settings_to_file(settings):
|
||||||
|
# 更新调度器配置
|
||||||
|
scheduler.interval_minutes = request.validate_interval_minutes
|
||||||
|
|
||||||
|
# 如果自动验证状态改变,启动或停止调度器
|
||||||
|
if request.auto_validate and not scheduler.running:
|
||||||
|
await scheduler.start()
|
||||||
|
elif not request.auto_validate and scheduler.running:
|
||||||
|
await scheduler.stop()
|
||||||
|
|
||||||
|
return {"code": 200, "message": "保存设置成功", "data": settings}
|
||||||
|
else:
|
||||||
|
return {"code": 500, "message": "保存设置失败", "data": None}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"保存设置失败: {e}")
|
||||||
|
return {"code": 500, "message": f"保存设置失败: {str(e)}", "data": None}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8923)
|
uvicorn.run(app, host=config.HOST, port=config.PORT)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Config:
|
|||||||
|
|
||||||
# API服务配置
|
# API服务配置
|
||||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||||
PORT: int = int(os.getenv("PORT", "3000"))
|
PORT: int = int(os.getenv("PORT", "9949"))
|
||||||
|
|
||||||
# 验证器配置
|
# 验证器配置
|
||||||
VALIDATOR_TIMEOUT: int = int(os.getenv("VALIDATOR_TIMEOUT", "5"))
|
VALIDATOR_TIMEOUT: int = int(os.getenv("VALIDATOR_TIMEOUT", "5"))
|
||||||
@@ -49,11 +49,6 @@ class Config:
|
|||||||
# CORS配置
|
# CORS配置
|
||||||
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:8080,http://localhost:5173")
|
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:8080,http://localhost:5173")
|
||||||
|
|
||||||
# API Key配置
|
|
||||||
API_KEY: str = os.getenv("API_KEY", "your-api-key-here")
|
|
||||||
ADMIN_API_KEY: str = os.getenv("ADMIN_API_KEY", "your-admin-api-key-here")
|
|
||||||
REQUIRE_AUTH: bool = os.getenv("REQUIRE_AUTH", "false").lower() == "true"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, key: str, default=None):
|
def get(cls, key: str, default=None):
|
||||||
"""获取配置项"""
|
"""获取配置项"""
|
||||||
|
|||||||
114
core/auth.py
114
core/auth.py
@@ -1,114 +0,0 @@
|
|||||||
from fastapi import HTTPException, Depends, Header, status
|
|
||||||
from typing import Optional
|
|
||||||
from config import Config
|
|
||||||
from core.log import logger
|
|
||||||
|
|
||||||
class PermissionLevel:
|
|
||||||
READ_ONLY = "read_only"
|
|
||||||
ADMIN = "admin"
|
|
||||||
|
|
||||||
def verify_api_key(
|
|
||||||
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
|
||||||
authorization: Optional[str] = Header(None)
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
验证API Key并返回权限级别
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x_api_key: X-API-Key header中的API Key
|
|
||||||
authorization: Authorization header中的Bearer token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 权限级别
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 认证失败时抛出401错误
|
|
||||||
"""
|
|
||||||
api_key = x_api_key
|
|
||||||
|
|
||||||
if authorization and authorization.startswith("Bearer "):
|
|
||||||
api_key = authorization.replace("Bearer ", "")
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
logger.warning("API请求缺少API Key")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="缺少API Key,请在请求头中添加 X-API-Key 或 Authorization: Bearer <key>",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if api_key == Config.ADMIN_API_KEY:
|
|
||||||
logger.info(f"管理员API认证成功: {api_key[:8]}...")
|
|
||||||
return PermissionLevel.ADMIN
|
|
||||||
elif api_key == Config.API_KEY:
|
|
||||||
logger.info(f"普通用户API认证成功: {api_key[:8]}...")
|
|
||||||
return PermissionLevel.READ_ONLY
|
|
||||||
else:
|
|
||||||
logger.warning(f"无效的API Key尝试: {api_key[:8]}...")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="无效的API Key",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def require_admin(
|
|
||||||
x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
|
|
||||||
authorization: Optional[str] = Header(None)
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
要求管理员权限的依赖函数
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x_api_key: X-API-Key header中的API Key
|
|
||||||
authorization: Authorization header中的Bearer token
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 权限级别
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 权限不足时抛出403错误
|
|
||||||
"""
|
|
||||||
# 如果未启用认证,直接返回管理员权限
|
|
||||||
if not Config.REQUIRE_AUTH:
|
|
||||||
logger.info("开发模式:跳过管理员权限检查")
|
|
||||||
return PermissionLevel.ADMIN
|
|
||||||
|
|
||||||
# 验证API Key
|
|
||||||
api_key = x_api_key
|
|
||||||
|
|
||||||
if authorization and authorization.startswith("Bearer "):
|
|
||||||
api_key = authorization.replace("Bearer ", "")
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="缺少API Key,请在请求头中添加 X-API-Key 或 Authorization: Bearer <key>",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查权限级别
|
|
||||||
if api_key == Config.ADMIN_API_KEY:
|
|
||||||
logger.info(f"管理员API认证成功: {api_key[:8]}...")
|
|
||||||
return PermissionLevel.ADMIN
|
|
||||||
else:
|
|
||||||
logger.warning(f"非管理员用户尝试访问管理接口")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="需要管理员权限才能执行此操作"
|
|
||||||
)
|
|
||||||
|
|
||||||
def skip_auth_for_dev() -> Optional[str]:
|
|
||||||
"""
|
|
||||||
开发环境跳过认证(仅在开发模式下使用)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[str]: 返回管理员权限级别
|
|
||||||
|
|
||||||
Warning:
|
|
||||||
仅用于开发环境,生产环境务必使用真实认证
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
if os.getenv("SKIP_AUTH", "false").lower() == "true":
|
|
||||||
logger.warning("开发模式:跳过API Key认证")
|
|
||||||
return PermissionLevel.ADMIN
|
|
||||||
return None
|
|
||||||
206
core/scheduler.py
Normal file
206
core/scheduler.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
代理验证调度器
|
||||||
|
负责定期验证数据库中的代理,并更新分数
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from core.sqlite import SQLiteManager
|
||||||
|
from core.validator import ProxyValidator
|
||||||
|
from core.log import logger
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationScheduler:
|
||||||
|
"""代理验证调度器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.db = SQLiteManager()
|
||||||
|
self.validator: Optional[ProxyValidator] = None
|
||||||
|
self.running = False
|
||||||
|
self.task: Optional[asyncio.Task] = None
|
||||||
|
self.interval_minutes = 30 # 默认每30分钟验证一次
|
||||||
|
self.batch_size = 100 # 每批验证数量
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""启动验证调度器"""
|
||||||
|
if self.running:
|
||||||
|
logger.warning("验证调度器已在运行")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
self.validator = ProxyValidator(
|
||||||
|
max_concurrency=config.VALIDATOR_MAX_CONCURRENCY,
|
||||||
|
timeout=config.VALIDATOR_TIMEOUT
|
||||||
|
)
|
||||||
|
self.task = asyncio.create_task(self._run_loop())
|
||||||
|
logger.info("代理验证调度器已启动")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""停止验证调度器"""
|
||||||
|
self.running = False
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel()
|
||||||
|
try:
|
||||||
|
await self.task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self.validator:
|
||||||
|
await self.validator.__aexit__(None, None, None)
|
||||||
|
logger.info("代理验证调度器已停止")
|
||||||
|
|
||||||
|
async def _run_loop(self):
|
||||||
|
"""运行循环"""
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self.validate_all_proxies()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"验证循环出错: {e}")
|
||||||
|
|
||||||
|
# 等待下一次验证
|
||||||
|
await asyncio.sleep(self.interval_minutes * 60)
|
||||||
|
|
||||||
|
async def validate_all_proxies(self):
|
||||||
|
"""验证所有代理"""
|
||||||
|
logger.info("开始批量验证代理...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取所有代理
|
||||||
|
proxies = await self.db.get_all_proxies()
|
||||||
|
if not proxies:
|
||||||
|
logger.info("数据库中没有代理需要验证")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"需要验证 {len(proxies)} 个代理")
|
||||||
|
|
||||||
|
# 分批验证
|
||||||
|
validated_count = 0
|
||||||
|
valid_count = 0
|
||||||
|
invalid_count = 0
|
||||||
|
|
||||||
|
async with self.validator:
|
||||||
|
for i in range(0, len(proxies), self.batch_size):
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
batch = proxies[i:i + self.batch_size]
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for proxy in batch:
|
||||||
|
ip, port, protocol, score, last_check = proxy
|
||||||
|
task = self._validate_and_update(ip, port, protocol)
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
# 并发验证一批
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
validated_count += 1
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(f"验证过程出错: {result}")
|
||||||
|
continue
|
||||||
|
if result:
|
||||||
|
valid_count += 1
|
||||||
|
else:
|
||||||
|
invalid_count += 1
|
||||||
|
|
||||||
|
logger.info(f"已验证 {validated_count}/{len(proxies)} 个代理")
|
||||||
|
|
||||||
|
# 批次间短暂延迟,避免过载
|
||||||
|
if i + self.batch_size < len(proxies):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
logger.info(f"验证完成: 总计 {validated_count}, 有效 {valid_count}, 无效 {invalid_count}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"批量验证代理失败: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _validate_and_update(self, ip: str, port: int, protocol: str) -> bool:
|
||||||
|
"""验证单个代理并更新分数"""
|
||||||
|
try:
|
||||||
|
is_valid, latency = await self.validator.validate(ip, port, protocol)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
# 验证成功,增加分数
|
||||||
|
await self.db.update_score(
|
||||||
|
ip, port,
|
||||||
|
config.SCORE_VALID,
|
||||||
|
min_score=config.SCORE_MIN,
|
||||||
|
max_score=config.SCORE_MAX
|
||||||
|
)
|
||||||
|
logger.debug(f"代理验证成功 {ip}:{port} ({protocol}) - 延迟 {latency}ms")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 验证失败,减少分数
|
||||||
|
await self.db.update_score(
|
||||||
|
ip, port,
|
||||||
|
config.SCORE_INVALID,
|
||||||
|
min_score=config.SCORE_MIN,
|
||||||
|
max_score=config.SCORE_MAX
|
||||||
|
)
|
||||||
|
logger.debug(f"代理验证失败 {ip}:{port} ({protocol})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"验证代理 {ip}:{port} 时出错: {e}")
|
||||||
|
# 出错也视为失败
|
||||||
|
await self.db.update_score(
|
||||||
|
ip, port,
|
||||||
|
config.SCORE_INVALID,
|
||||||
|
min_score=config.SCORE_MIN,
|
||||||
|
max_score=config.SCORE_MAX
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def validate_proxies_batch(self, proxies: list) -> tuple:
|
||||||
|
"""
|
||||||
|
验证一批新抓取的代理
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxies: [(ip, port, protocol), ...]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(有效代理列表, 无效代理列表)
|
||||||
|
"""
|
||||||
|
if not proxies:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
valid_proxies = []
|
||||||
|
invalid_proxies = []
|
||||||
|
|
||||||
|
logger.info(f"开始验证 {len(proxies)} 个新抓取代理...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
validator = ProxyValidator(
|
||||||
|
max_concurrency=min(config.VALIDATOR_MAX_CONCURRENCY, 50),
|
||||||
|
timeout=config.VALIDATOR_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
async with validator:
|
||||||
|
tasks = []
|
||||||
|
for ip, port, protocol in proxies:
|
||||||
|
task = validator.validate(ip, port, protocol)
|
||||||
|
tasks.append((ip, port, protocol, task))
|
||||||
|
|
||||||
|
for ip, port, protocol, task in tasks:
|
||||||
|
try:
|
||||||
|
is_valid, latency = await task
|
||||||
|
if is_valid:
|
||||||
|
valid_proxies.append((ip, port, protocol))
|
||||||
|
logger.debug(f"新代理有效: {ip}:{port} ({protocol}) - {latency}ms")
|
||||||
|
else:
|
||||||
|
invalid_proxies.append((ip, port, protocol))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"验证新代理 {ip}:{port} 失败: {e}")
|
||||||
|
invalid_proxies.append((ip, port, protocol))
|
||||||
|
|
||||||
|
logger.info(f"新代理验证完成: 有效 {len(valid_proxies)}, 无效 {len(invalid_proxies)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"批量验证新代理失败: {e}")
|
||||||
|
|
||||||
|
return valid_proxies, invalid_proxies
|
||||||
|
|
||||||
|
|
||||||
|
# 全局调度器实例
|
||||||
|
scheduler = ValidationScheduler()
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import aiohttp_socks
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from core.log import logger
|
from core.log import logger
|
||||||
|
|
||||||
|
|
||||||
class ProxyValidator:
|
class ProxyValidator:
|
||||||
|
"""代理验证器 - 支持 HTTP/HTTPS/SOCKS4/SOCKS5"""
|
||||||
|
|
||||||
def __init__(self, max_concurrency=50, timeout=5):
|
def __init__(self, max_concurrency=50, timeout=5):
|
||||||
# 验证目标源(使用更适合代理验证的源)
|
# 验证目标源
|
||||||
self.http_sources = [
|
self.http_sources = [
|
||||||
"http://httpbin.org/ip",
|
"http://httpbin.org/ip",
|
||||||
"http://api.ipify.org"
|
"http://api.ipify.org"
|
||||||
@@ -20,57 +24,169 @@ class ProxyValidator:
|
|||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
# 允许通过 async with 管理 session
|
"""异步上下文管理器入口"""
|
||||||
if not self.session:
|
|
||||||
self.session = aiohttp.ClientSession(
|
|
||||||
connector=aiohttp.TCPConnector(ssl=False, limit=0, force_close=True),
|
|
||||||
timeout=aiohttp.ClientTimeout(total=self.timeout, connect=3)
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""异步上下文管理器出口"""
|
||||||
if self.session:
|
if self.session:
|
||||||
await self.session.close()
|
await self.session.close()
|
||||||
|
self.session = None
|
||||||
|
|
||||||
async def validate(self, ip, port, protocol='http'):
|
def _get_test_url(self, protocol: str) -> str:
|
||||||
|
"""根据协议获取测试 URL"""
|
||||||
|
protocol = protocol.lower()
|
||||||
|
if protocol == 'https':
|
||||||
|
return random.choice(self.https_sources)
|
||||||
|
return random.choice(self.http_sources)
|
||||||
|
|
||||||
|
def _create_connector(self, ip: str, port: int, protocol: str):
|
||||||
|
"""创建代理连接器"""
|
||||||
|
protocol = protocol.lower()
|
||||||
|
|
||||||
|
if protocol == 'socks4':
|
||||||
|
return aiohttp_socks.ProxyConnector(
|
||||||
|
proxy_type=aiohttp_socks.ProxyType.SOCKS4,
|
||||||
|
host=ip,
|
||||||
|
port=port,
|
||||||
|
rdns=True
|
||||||
|
)
|
||||||
|
elif protocol == 'socks5':
|
||||||
|
return aiohttp_socks.ProxyConnector(
|
||||||
|
proxy_type=aiohttp_socks.ProxyType.SOCKS5,
|
||||||
|
host=ip,
|
||||||
|
port=port,
|
||||||
|
rdns=True
|
||||||
|
)
|
||||||
|
elif protocol in ('http', 'https'):
|
||||||
|
# HTTP/HTTPS 使用普通 connector,在请求时指定 proxy 参数
|
||||||
|
return aiohttp.TCPConnector(ssl=False, limit=0, force_close=True)
|
||||||
|
else:
|
||||||
|
# 未知协议默认使用 HTTP
|
||||||
|
return aiohttp.TCPConnector(ssl=False, limit=0, force_close=True)
|
||||||
|
|
||||||
|
async def validate(self, ip: str, port: int, protocol: str = 'http'):
|
||||||
"""
|
"""
|
||||||
验证单个代理是否可用
|
验证单个代理是否可用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: 代理 IP
|
||||||
|
port: 代理端口
|
||||||
|
protocol: 协议类型 (http/https/socks4/socks5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid: bool, latency_ms: float)
|
||||||
"""
|
"""
|
||||||
protocol = protocol.lower()
|
protocol = protocol.lower()
|
||||||
sources = self.https_sources if protocol == 'https' else self.http_sources
|
test_url = self._get_test_url(protocol)
|
||||||
test_url = random.choice(sources)
|
|
||||||
|
|
||||||
# aiohttp 代理 URL 格式
|
|
||||||
proxy_url = f"http://{ip}:{port}"
|
|
||||||
|
|
||||||
async with self.semaphore:
|
async with self.semaphore:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 复用 session
|
if protocol in ('socks4', 'socks5'):
|
||||||
async with self.session.get(
|
return await self._validate_socks(ip, port, protocol, test_url, start_time)
|
||||||
test_url,
|
else:
|
||||||
proxy=proxy_url,
|
return await self._validate_http(ip, port, protocol, test_url, start_time)
|
||||||
allow_redirects=True,
|
|
||||||
timeout=aiohttp.ClientTimeout(total=self.timeout, connect=3)
|
|
||||||
) as response:
|
|
||||||
# 检查状态码和响应内容
|
|
||||||
if response.status in [200, 301, 302]:
|
|
||||||
try:
|
|
||||||
content = await response.text()
|
|
||||||
# 确保返回了有效的JSON响应
|
|
||||||
if 'ip' in content.lower() or 'origin' in content.lower():
|
|
||||||
latency = round((time.time() - start_time) * 1000, 2)
|
|
||||||
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
|
||||||
return True, latency
|
|
||||||
except:
|
|
||||||
# 即使无法解析内容,如果状态码正常也认为可用
|
|
||||||
latency = round((time.time() - start_time) * 1000, 2)
|
|
||||||
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
|
||||||
return True, latency
|
|
||||||
return False, 0
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning(f"验证超时: {ip}:{port} ({protocol})")
|
logger.warning(f"验证超时: {ip}:{port} ({protocol})")
|
||||||
return False, 0
|
return False, 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"验证失败: {ip}:{port} ({protocol}) - {e}")
|
logger.warning(f"验证失败: {ip}:{port} ({protocol}) - {e}")
|
||||||
return False, 0
|
return False, 0
|
||||||
|
|
||||||
|
async def _validate_http(self, ip: str, port: int, protocol: str, test_url: str, start_time: float):
|
||||||
|
"""验证 HTTP/HTTPS 代理"""
|
||||||
|
proxy_url = f"http://{ip}:{port}"
|
||||||
|
|
||||||
|
connector = aiohttp.TCPConnector(ssl=False, limit=0, force_close=True)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout, connect=3)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
timeout=timeout
|
||||||
|
) as session:
|
||||||
|
async with session.get(
|
||||||
|
test_url,
|
||||||
|
proxy=proxy_url,
|
||||||
|
allow_redirects=True
|
||||||
|
) as response:
|
||||||
|
if response.status in [200, 301, 302]:
|
||||||
|
try:
|
||||||
|
content = await response.text()
|
||||||
|
if 'ip' in content.lower() or 'origin' in content.lower():
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
||||||
|
return True, latency
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 内容解析失败但状态码正常,也算可用
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
||||||
|
return True, latency
|
||||||
|
|
||||||
|
return False, 0
|
||||||
|
|
||||||
|
async def _validate_socks(self, ip: str, port: int, protocol: str, test_url: str, start_time: float):
|
||||||
|
"""验证 SOCKS4/SOCKS5 代理"""
|
||||||
|
proxy_type = (
|
||||||
|
aiohttp_socks.ProxyType.SOCKS4
|
||||||
|
if protocol == 'socks4'
|
||||||
|
else aiohttp_socks.ProxyType.SOCKS5
|
||||||
|
)
|
||||||
|
|
||||||
|
connector = aiohttp_socks.ProxyConnector(
|
||||||
|
proxy_type=proxy_type,
|
||||||
|
host=ip,
|
||||||
|
port=port,
|
||||||
|
rdns=True, # 远程 DNS 解析,避免 DNS 泄漏
|
||||||
|
ssl=False
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout, connect=3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
timeout=timeout
|
||||||
|
) as session:
|
||||||
|
async with session.get(test_url, allow_redirects=True) as response:
|
||||||
|
if response.status in [200, 301, 302]:
|
||||||
|
try:
|
||||||
|
content = await response.text()
|
||||||
|
if 'ip' in content.lower() or 'origin' in content.lower():
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
||||||
|
return True, latency
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 内容解析失败但状态码正常
|
||||||
|
latency = round((time.time() - start_time) * 1000, 2)
|
||||||
|
logger.info(f"验证成功: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
||||||
|
return True, latency
|
||||||
|
|
||||||
|
return False, 0
|
||||||
|
finally:
|
||||||
|
await connector.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyValidatorLegacy:
|
||||||
|
"""
|
||||||
|
兼容旧版本的验证器
|
||||||
|
保持原有接口不变
|
||||||
|
"""
|
||||||
|
def __init__(self, max_concurrency=50, timeout=5):
|
||||||
|
self.validator = ProxyValidator(max_concurrency, timeout)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
await self.validator.__aenter__()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
await self.validator.__aexit__(exc_type, exc_val, exc_tb)
|
||||||
|
|
||||||
|
async def validate(self, ip, port, protocol='http'):
|
||||||
|
return await self.validator.validate(ip, port, protocol)
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>代理池管理系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<el-menu
|
<aside class="sidebar">
|
||||||
:default-active="activeMenu"
|
|
||||||
class="side-menu"
|
|
||||||
router
|
|
||||||
>
|
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<div class="logo">🌸</div>
|
<el-icon class="logo" :size="40"><Grid /></el-icon>
|
||||||
<div class="logo-text">代理池</div>
|
<h1 class="logo-text">代理池</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu-item index="/dashboard">
|
<nav class="menu-nav">
|
||||||
<template #title>
|
<router-link
|
||||||
<span class="menu-icon">🏠</span>
|
v-for="item in menuItems"
|
||||||
<span>总览</span>
|
:key="item.index"
|
||||||
</template>
|
:to="item.index"
|
||||||
</el-menu-item>
|
:class="['menu-item', { active: isActive(item.index) }]"
|
||||||
|
>
|
||||||
<el-menu-item index="/proxies">
|
<el-icon class="menu-icon" :size="18">
|
||||||
<template #title>
|
<component :is="item.icon" />
|
||||||
<span class="menu-icon">📋</span>
|
</el-icon>
|
||||||
<span>代理列表</span>
|
<span class="menu-label">{{ item.label }}</span>
|
||||||
</template>
|
</router-link>
|
||||||
</el-menu-item>
|
</nav>
|
||||||
|
</aside>
|
||||||
<el-menu-item index="/crawler">
|
|
||||||
<template #title>
|
|
||||||
<span class="menu-icon">🎀</span>
|
|
||||||
<span>任务管理</span>
|
|
||||||
</template>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item index="/plugins">
|
|
||||||
<template #title>
|
|
||||||
<span class="menu-icon">🔌</span>
|
|
||||||
<span>插件管理</span>
|
|
||||||
</template>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item index="/settings">
|
|
||||||
<template #title>
|
|
||||||
<span class="menu-icon">⚙️</span>
|
|
||||||
<span>设置</span>
|
|
||||||
</template>
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
|
|
||||||
<div class="main-content">
|
<main class="main-content">
|
||||||
<RouterView />
|
<router-view v-slot="{ Component }">
|
||||||
</div>
|
<transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<style scoped>
|
||||||
.app-container {
|
.app-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu {
|
/* 侧边栏 - 冷灰紫风格 */
|
||||||
width: 240px;
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
box-shadow: 4px 0 20px rgba(255, 107, 157, 0.1);
|
z-index: 100;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
display: flex;
|
||||||
backdrop-filter: blur(10px);
|
flex-direction: column;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-section {
|
.logo-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 35px 0;
|
padding: 24px 20px;
|
||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.logo {
|
||||||
font-size: 52px;
|
color: var(--primary);
|
||||||
margin-bottom: 10px;
|
margin-right: 12px;
|
||||||
animation: float 3s ease-in-out infinite;
|
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 22px;
|
font-size: 18px;
|
||||||
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-weight: 600;
|
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;
|
position: relative;
|
||||||
overflow: hidden;
|
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: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 50%;
|
||||||
height: 100%;
|
transform: translateY(-50%);
|
||||||
width: 3px;
|
width: 3px;
|
||||||
|
height: 20px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
transform: scaleY(0);
|
border-radius: 0 2px 2px 0;
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item:hover) {
|
.menu-icon {
|
||||||
background: rgba(0, 212, 255, 0.1) !important;
|
margin-right: 12px;
|
||||||
color: var(--primary);
|
flex-shrink: 0;
|
||||||
transform: translateX(8px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item:hover::before) {
|
.menu-label {
|
||||||
transform: scaleY(1);
|
white-space: nowrap;
|
||||||
}
|
font-size: 14px;
|
||||||
|
|
||||||
: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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主内容区 */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,73 +1,160 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { showError } from '../utils/message'
|
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({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8923',
|
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
|
||||||
timeout: 30000
|
timeout: REQUEST_TIMEOUT
|
||||||
})
|
})
|
||||||
|
|
||||||
api.interceptors.request.use(
|
/**
|
||||||
config => {
|
* 从 Blob 解析 JSON 错误响应
|
||||||
const apiKey = localStorage.getItem('api_key')
|
* @param {Blob} blob
|
||||||
if (apiKey) {
|
* @returns {Promise<object|null>}
|
||||||
config.headers['X-API-Key'] = apiKey
|
*/
|
||||||
}
|
async function parseBlobError(blob) {
|
||||||
return config
|
try {
|
||||||
},
|
const text = await blob.text()
|
||||||
error => {
|
return JSON.parse(text)
|
||||||
return Promise.reject(error)
|
} catch {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
response => response.data,
|
(response) => response.data,
|
||||||
error => {
|
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)
|
console.error('API请求错误:', error)
|
||||||
showError(error)
|
showError(error)
|
||||||
return Promise.reject(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 = {
|
export const statsAPI = {
|
||||||
|
/** @returns {Promise<import('./types').ApiResponse<import('./types').StatsData>>} */
|
||||||
getStats: () => api.get('/api/stats')
|
getStats: () => api.get('/api/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const proxiesAPI = {
|
export const proxiesAPI = {
|
||||||
getProxies: (params) => {
|
/**
|
||||||
const cleanedParams = {}
|
* @param {object} params
|
||||||
Object.keys(params).forEach(key => {
|
* @param {AbortSignal} [signal]
|
||||||
if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
|
* @returns {Promise<import('./types').ApiResponse<import('./types').ProxyListData>>}
|
||||||
cleanedParams[key] = params[key]
|
*/
|
||||||
}
|
getProxies: (params, signal) =>
|
||||||
})
|
api.post('/api/proxies', cleanParams(params), createRequestConfig(signal)),
|
||||||
return api.post('/api/proxies', cleanedParams)
|
|
||||||
},
|
/**
|
||||||
getRandomProxy: () => api.get('/api/proxies/random'),
|
* @param {string} ip
|
||||||
getProxyDetail: (ip, port) => api.get(`/api/proxies/${ip}/${port}`),
|
* @param {number|string} port
|
||||||
|
* @returns {Promise<import('./types').ApiResponse<any>>}
|
||||||
|
*/
|
||||||
deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`),
|
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 }),
|
batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }),
|
||||||
|
|
||||||
|
/** @returns {Promise<import('./types').ApiResponse<{deleted_count: number}>>} */
|
||||||
cleanInvalidProxies: () => api.delete('/api/proxies/clean-invalid'),
|
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}`, {
|
exportProxies: (format, protocol) => api.get(`/api/proxies/export/${format}`, {
|
||||||
params: protocol ? { protocol } : {},
|
params: protocol ? { protocol } : {},
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const crawlerAPI = {
|
export const pluginsAPI = {
|
||||||
start: (numValidators = 50) => api.post('/api/crawler/start', { num_validators: numValidators }),
|
/** @returns {Promise<import('./types').ApiResponse<{plugins: import('./types').Plugin[] }>>} */
|
||||||
stop: () => api.post('/api/crawler/stop'),
|
getPlugins: () => api.get('/api/plugins'),
|
||||||
getStatus: () => api.get('/api/crawler/status')
|
|
||||||
|
/**
|
||||||
|
* @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 = {
|
export const schedulerAPI = {
|
||||||
setScheduler: (enabled, intervalMinutes = 60) => api.post('/api/scheduler', { enabled, interval_minutes: intervalMinutes }),
|
/** @returns {Promise<import('./types').ApiResponse<{running: boolean}>>} */
|
||||||
getStatus: () => api.get('/api/scheduler')
|
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 = {
|
export const settingsAPI = {
|
||||||
getPlugins: () => api.get('/api/plugins'),
|
/** @returns {Promise<import('./types').ApiResponse<import('./types').SettingsData>>} */
|
||||||
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
|
getSettings: () => api.get('/api/settings'),
|
||||||
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`)
|
|
||||||
|
/**
|
||||||
|
* @param {object} data
|
||||||
|
* @returns {Promise<import('./types').ApiResponse<any>>}
|
||||||
|
*/
|
||||||
|
saveSettings: (data) => api.post('/api/settings', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
57
frontend/src/api/types.js
Normal file
57
frontend/src/api/types.js
Normal 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 {}
|
||||||
@@ -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>
|
|
||||||
@@ -1,18 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card class="header-card" shadow="hover">
|
<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>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 页面标题组件 - 冷灰紫主题
|
||||||
|
* @description 统一的页面头部展示
|
||||||
|
*/
|
||||||
defineProps({
|
defineProps({
|
||||||
|
/** 页面标题 */
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
/** 图标组件或组件名称 */
|
||||||
icon: {
|
icon: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
/** 副标题 */
|
||||||
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '📄'
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -20,15 +37,45 @@ defineProps({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.header-card {
|
.header-card {
|
||||||
margin-bottom: 20px;
|
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 {
|
.title {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 28px;
|
flex-shrink: 0;
|
||||||
font-weight: 700;
|
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.3));
|
||||||
letter-spacing: 2px;
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,18 +2,28 @@
|
|||||||
<el-card class="chart-card" shadow="hover">
|
<el-card class="chart-card" shadow="hover">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-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>
|
</div>
|
||||||
</template>
|
</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>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { InfoFilled, PieChart } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
/** 统计数据 */
|
||||||
data: {
|
data: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
@@ -22,17 +32,51 @@ const props = defineProps({
|
|||||||
|
|
||||||
const chartRef = ref(null)
|
const chartRef = ref(null)
|
||||||
let chartInstance = 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' } },
|
const hasData = computed(() => {
|
||||||
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: '#00A8CC' } },
|
const { http_count, https_count, socks4_count, socks5_count } = props.data
|
||||||
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: '#7B68EE' } },
|
return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0
|
||||||
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: '#FF3366' } }
|
})
|
||||||
])
|
|
||||||
|
|
||||||
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() {
|
function getChartOption() {
|
||||||
|
const colors = cachedColors.value
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
@@ -40,11 +84,11 @@ function getChartOption() {
|
|||||||
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
|
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
|
||||||
return `${params.name}: ${params.value} (${percent}%)`
|
return `${params.name}: ${params.value} (${percent}%)`
|
||||||
},
|
},
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'rgba(24, 28, 37, 0.95)',
|
||||||
borderColor: '#FF6B9D',
|
borderColor: colors.primary,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: '#333',
|
color: colors.textPrimary,
|
||||||
fontSize: 14
|
fontSize: 14
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,20 +97,20 @@ function getChartOption() {
|
|||||||
right: 10,
|
right: 10,
|
||||||
top: 'center',
|
top: 'center',
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: '#666',
|
color: colors.textSecondary,
|
||||||
fontSize: 14
|
fontSize: 13
|
||||||
},
|
},
|
||||||
itemGap: 20
|
itemGap: 16
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['45%', '70%'],
|
radius: ['40%', '65%'],
|
||||||
center: ['35%', '50%'],
|
center: ['38%', '50%'],
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
borderColor: '#FFFFFF',
|
borderColor: colors.surface,
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
@@ -75,15 +119,15 @@ function getChartOption() {
|
|||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#333',
|
color: colors.textPrimary,
|
||||||
formatter: '{b}\n{c}个'
|
formatter: '{b}\n{c}个'
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
shadowBlur: 8,
|
shadowBlur: 10,
|
||||||
shadowOffsetX: 0,
|
shadowOffsetX: 0,
|
||||||
shadowColor: 'rgba(255, 107, 157, 0.2)'
|
shadowColor: 'rgba(146, 124, 255, 0.3)'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animationType: 'scale',
|
animationType: 'scale',
|
||||||
@@ -96,8 +140,9 @@ function getChartOption() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initChart() {
|
function initChart() {
|
||||||
if (!chartRef.value) return
|
if (!chartRef.value || !hasData.value) return
|
||||||
|
|
||||||
|
loadColors()
|
||||||
chartInstance = echarts.init(chartRef.value)
|
chartInstance = echarts.init(chartRef.value)
|
||||||
updateChart()
|
updateChart()
|
||||||
|
|
||||||
@@ -105,35 +150,79 @@ function initChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (!chartInstance) return
|
if (!chartInstance || !hasData.value) return
|
||||||
chartInstance.setOption(getChartOption(), true)
|
chartInstance.setOption(getChartOption(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize() {
|
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, () => {
|
watch(() => props.data, () => {
|
||||||
updateChart()
|
if (!chartInstance && hasData.value) {
|
||||||
|
initChart()
|
||||||
|
} else {
|
||||||
|
updateChart()
|
||||||
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initChart()
|
if (hasData.value) {
|
||||||
|
initChart()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
destroyChart()
|
||||||
chartInstance?.dispose()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart-card {
|
.chart-card {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
min-height: 400px;
|
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 {
|
.chart-container {
|
||||||
height: 350px;
|
height: 350px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,83 +1,144 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card class="chart-card" shadow="hover">
|
<el-card class="actions-card" shadow="hover">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">🎯 快速操作</span>
|
<span class="card-title">
|
||||||
|
<el-icon class="header-icon"><Lightning /></el-icon>
|
||||||
|
快速操作
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<el-button
|
<button
|
||||||
type="primary"
|
class="action-btn btn-success"
|
||||||
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"
|
|
||||||
@click="$emit('export')"
|
@click="$emit('export')"
|
||||||
>
|
>
|
||||||
<span class="btn-icon">📥</span>
|
<span class="btn-content">
|
||||||
导出代理
|
<el-icon class="btn-icon"><Download /></el-icon>
|
||||||
</el-button>
|
<span class="btn-text">导出代理</span>
|
||||||
<el-button
|
</span>
|
||||||
type="warning"
|
</button>
|
||||||
size="large"
|
|
||||||
class="action-btn"
|
<button
|
||||||
|
class="action-btn btn-warning"
|
||||||
@click="$emit('clean')"
|
@click="$emit('clean')"
|
||||||
>
|
>
|
||||||
<span class="btn-icon">🧹</span>
|
<span class="btn-content">
|
||||||
清理无效
|
<el-icon class="btn-icon"><Delete /></el-icon>
|
||||||
</el-button>
|
<span class="btn-text">清理无效</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['start-crawler', 'export', 'clean'])
|
defineEmits(['export', 'clean'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart-card {
|
.actions-card {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
min-height: 400px;
|
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 {
|
.quick-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
gap: 15px;
|
gap: 12px;
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
height: 56px;
|
||||||
font-size: 16px;
|
border: none;
|
||||||
border-radius: 14px;
|
border-radius: var(--radius-md);
|
||||||
font-weight: 700;
|
font-size: 15px;
|
||||||
letter-spacing: 0.5px;
|
font-weight: 500;
|
||||||
box-shadow: var(--shadow-md);
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.btn-content {
|
||||||
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.25);
|
display: flex;
|
||||||
transform: translateY(-5px) scale(1.02);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card :class="['stat-card', type]" shadow="hover">
|
<el-card :class="['stat-card', type]" shadow="hover">
|
||||||
<div class="stat-content">
|
<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-info">
|
||||||
<div class="stat-value">{{ value }}</div>
|
<div class="stat-value" :title="String(value)">{{ displayValue }}</div>
|
||||||
<div class="stat-label">{{ label }}</div>
|
<div class="stat-label">{{ label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,88 +13,124 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡片组件 - 冷灰紫主题
|
||||||
|
* @description 用于展示 Dashboard 上的统计数据
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
/** 卡片类型,影响背景色 */
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'default'
|
default: 'default',
|
||||||
|
validator: (value) => ['default', 'total', 'available', 'new', 'score'].includes(value)
|
||||||
},
|
},
|
||||||
|
/** 图标组件 */
|
||||||
icon: {
|
icon: {
|
||||||
type: String,
|
type: [String, Object],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
/** 数值 */
|
||||||
value: {
|
value: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
/** 标签 */
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-card {
|
.stat-card {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
min-height: 120px;
|
min-height: 100px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(255, 107, 157, 0.15);
|
border: 1px solid var(--border);
|
||||||
transition: var(--transition-hover);
|
transition: var(--transition-hover);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
transform: translateY(-8px) scale(1.02);
|
border-color: var(--border-light);
|
||||||
box-shadow: 0 8px 24px rgba(255, 107, 157, 0.15);
|
transform: translateY(-2px);
|
||||||
border-color: var(--cyan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.stat-card.available .stat-icon {
|
||||||
background-color: rgba(0, 255, 136, 0.1);
|
color: var(--success);
|
||||||
|
filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.new {
|
.stat-card.new .stat-icon {
|
||||||
background-color: rgba(255, 184, 0, 0.1);
|
color: var(--warning);
|
||||||
|
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.score {
|
.stat-card.score .stat-icon {
|
||||||
background-color: rgba(168, 85, 247, 0.1);
|
color: var(--primary);
|
||||||
|
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content {
|
.stat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
font-size: 32px;
|
margin-right: 16px;
|
||||||
margin-right: 20px;
|
flex-shrink: 0;
|
||||||
filter: drop-shadow(0 0 15px rgba(255, 107, 157, 0.3));
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-info {
|
.stat-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 28px;
|
font-size: 26px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 5px;
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -15,11 +15,7 @@ const routes = [
|
|||||||
name: 'ProxyList',
|
name: 'ProxyList',
|
||||||
component: () => import('../views/ProxyList.vue')
|
component: () => import('../views/ProxyList.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/crawler',
|
|
||||||
name: 'CrawlerTasks',
|
|
||||||
component: () => import('../views/CrawlerTasks.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/plugins',
|
path: '/plugins',
|
||||||
name: 'Plugins',
|
name: 'Plugins',
|
||||||
@@ -33,7 +29,7 @@ const routes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHashHistory(),
|
||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,25 +1,48 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { pluginsAPI } from '../api'
|
import { pluginsAPI } from '../api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins Store
|
||||||
|
* 管理插件列表和状态
|
||||||
|
*/
|
||||||
export const usePluginsStore = defineStore('plugins', () => {
|
export const usePluginsStore = defineStore('plugins', () => {
|
||||||
|
// ==================== State ====================
|
||||||
const plugins = ref([])
|
const plugins = ref([])
|
||||||
const loading = ref(false)
|
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() {
|
async function fetchPlugins() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await pluginsAPI.getPlugins()
|
const response = await pluginsAPI.getPlugins()
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
plugins.value = response.data.plugins || []
|
plugins.value = response.data.plugins || []
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取插件列表失败:', error)
|
console.error('获取插件列表失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换插件启用状态
|
||||||
|
* @param {string|number} pluginId
|
||||||
|
* @param {boolean} enabled
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
async function togglePlugin(pluginId, enabled) {
|
async function togglePlugin(pluginId, enabled) {
|
||||||
try {
|
try {
|
||||||
const response = await pluginsAPI.togglePlugin(pluginId, enabled)
|
const response = await pluginsAPI.togglePlugin(pluginId, enabled)
|
||||||
@@ -35,24 +58,50 @@ export const usePluginsStore = defineStore('plugins', () => {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发插件爬取
|
||||||
|
* @param {string|number} pluginId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
async function crawlPlugin(pluginId) {
|
async function crawlPlugin(pluginId) {
|
||||||
try {
|
try {
|
||||||
const response = await pluginsAPI.crawlPlugin(pluginId)
|
const response = await pluginsAPI.crawlPlugin(pluginId)
|
||||||
if (response.code === 200) {
|
return response.code === 200
|
||||||
return true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('触发插件爬取失败:', 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 {
|
return {
|
||||||
|
// State
|
||||||
plugins,
|
plugins,
|
||||||
loading,
|
loading,
|
||||||
|
// Getters
|
||||||
|
enabledCount,
|
||||||
|
totalCount,
|
||||||
|
// Actions
|
||||||
fetchPlugins,
|
fetchPlugins,
|
||||||
togglePlugin,
|
togglePlugin,
|
||||||
crawlPlugin
|
crawlPlugin,
|
||||||
|
getPluginById,
|
||||||
|
reset
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,54 +2,99 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { proxiesAPI, statsAPI } from '../api'
|
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', () => {
|
export const useProxyStore = defineStore('proxy', () => {
|
||||||
|
// ==================== State ====================
|
||||||
const proxies = ref([])
|
const proxies = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const stats = ref({})
|
const stats = ref({})
|
||||||
|
|
||||||
const availableCount = computed(() => stats.value.available || 0)
|
// ==================== Getters ====================
|
||||||
const totalCount = computed(() => stats.value.total || 0)
|
const hasProxies = computed(() => proxies.value.length > 0)
|
||||||
|
const isEmpty = computed(() => !loading.value && proxies.value.length === 0)
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计信息
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
try {
|
try {
|
||||||
const response = await statsAPI.getStats()
|
const response = await statsAPI.getStats()
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
stats.value = response.data
|
stats.value = response.data
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取统计信息失败:', 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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await proxiesAPI.getProxies(params)
|
const response = await proxiesAPI.getProxies(params, signal)
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
proxies.value = response.data.list
|
proxies.value = response.data.list
|
||||||
total.value = response.data.total
|
total.value = response.data.total
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
console.error('获取代理列表失败:', error)
|
console.error('获取代理列表失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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
|
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) {
|
async function batchDeleteProxies(proxyList) {
|
||||||
|
if (!proxyList?.length) return 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await proxiesAPI.batchDeleteProxies(proxyList)
|
const response = await proxiesAPI.batchDeleteProxies(proxyList)
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
@@ -61,6 +106,10 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效代理
|
||||||
|
* @returns {Promise<number>} 删除的数量
|
||||||
|
*/
|
||||||
async function cleanInvalidProxies() {
|
async function cleanInvalidProxies() {
|
||||||
try {
|
try {
|
||||||
const response = await proxiesAPI.cleanInvalidProxies()
|
const response = await proxiesAPI.cleanInvalidProxies()
|
||||||
@@ -73,9 +122,17 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
return 0
|
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 {
|
try {
|
||||||
const response = await proxiesAPI.exportProxies(format, protocol)
|
const response = await proxiesAPI.exportProxies(format, protocol)
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
const url = window.URL.createObjectURL(new Blob([response]))
|
const url = window.URL.createObjectURL(new Blob([response]))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
@@ -84,25 +141,39 @@ export const useProxyStore = defineStore('proxy', () => {
|
|||||||
link.click()
|
link.click()
|
||||||
link.remove()
|
link.remove()
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导出代理失败:', error)
|
console.error('导出代理失败:', error)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置状态
|
||||||
|
*/
|
||||||
|
function reset() {
|
||||||
|
proxies.value = []
|
||||||
|
total.value = 0
|
||||||
|
stats.value = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// State
|
||||||
proxies,
|
proxies,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
stats,
|
stats,
|
||||||
availableCount,
|
// Getters
|
||||||
totalCount,
|
hasProxies,
|
||||||
|
isEmpty,
|
||||||
|
// Actions
|
||||||
fetchStats,
|
fetchStats,
|
||||||
fetchProxies,
|
fetchProxies,
|
||||||
deleteProxy,
|
deleteProxy,
|
||||||
batchDeleteProxies,
|
batchDeleteProxies,
|
||||||
cleanInvalidProxies,
|
cleanInvalidProxies,
|
||||||
exportProxies
|
exportProxies,
|
||||||
|
reset
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,43 +9,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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;
|
line-height: 1.6;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-page);
|
background: var(--bg);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--cyan);
|
color: var(--primary);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--cyan-light);
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 滚动条 - 深色主题 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: var(--bg-page);
|
background-color: var(--bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-light);
|
background-color: var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-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 {
|
@keyframes gradientShift {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
background-position: 0% 50%;
|
background-position: 0% 50%;
|
||||||
@@ -84,11 +94,11 @@ a:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progressShine {
|
@keyframes pulse-glow {
|
||||||
0% {
|
0%, 100% {
|
||||||
transform: translateX(-100%);
|
box-shadow: 0 0 5px rgba(146, 124, 255, 0.3);
|
||||||
}
|
}
|
||||||
100% {
|
50% {
|
||||||
transform: translateX(100%);
|
box-shadow: 0 0 20px rgba(146, 124, 255, 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
/* ==================== Element Plus 冷灰紫主题覆盖 ==================== */
|
||||||
|
|
||||||
|
/* -------------------- 输入框 -------------------- */
|
||||||
.el-input__wrapper {
|
.el-input__wrapper {
|
||||||
|
background-color: var(--surface-3) !important;
|
||||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input__wrapper:hover,
|
.el-input__wrapper:hover,
|
||||||
@@ -7,8 +12,23 @@
|
|||||||
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
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 {
|
.el-select__wrapper {
|
||||||
|
background-color: var(--surface-3) !important;
|
||||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select__wrapper:hover,
|
.el-select__wrapper:hover,
|
||||||
@@ -16,55 +36,67 @@
|
|||||||
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
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 {
|
.el-select__placeholder {
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select__caret {
|
.el-select__caret {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-select__caret.is-reverse {
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select-dropdown {
|
.el-select-dropdown {
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
box-shadow: var(--shadow-md) !important;
|
box-shadow: var(--shadow-lg) !important;
|
||||||
background: white !important;
|
background: var(--surface) !important;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select-dropdown__item {
|
.el-select-dropdown__item {
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select-dropdown__item:hover {
|
.el-select-dropdown__item:hover {
|
||||||
background: rgba(255, 107, 157, 0.1) !important;
|
background: var(--primary-soft) !important;
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select-dropdown__item.is-selected {
|
.el-select-dropdown__item.is-selected {
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
background: var(--primary-soft) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 数字输入框 -------------------- */
|
||||||
.el-input-number__decrease,
|
.el-input-number__decrease,
|
||||||
.el-input-number__increase {
|
.el-input-number__increase {
|
||||||
background: var(--bg-light) !important;
|
background: var(--surface-2) !important;
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input-number__decrease:hover,
|
.el-input-number__decrease:hover,
|
||||||
.el-input-number__increase:hover {
|
.el-input-number__increase:hover {
|
||||||
background: rgba(255, 107, 157, 0.1) !important;
|
background: var(--primary-soft) !important;
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
border-color: var(--primary) !important;
|
border-color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input-number__decrease.is-disabled,
|
.el-input-number__decrease.is-disabled,
|
||||||
.el-input-number__increase.is-disabled {
|
.el-input-number__increase.is-disabled {
|
||||||
color: #ccc !important;
|
color: var(--el-disabled-text) !important;
|
||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input-number__wrapper {
|
.el-input-number__wrapper {
|
||||||
|
background-color: var(--surface-3) !important;
|
||||||
box-shadow: 0 0 0 1px var(--border) inset !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;
|
box-shadow: 0 0 0 1px var(--primary) inset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 按钮 -------------------- */
|
||||||
.el-button {
|
.el-button {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-button:hover {
|
||||||
background: var(--gradient-primary) !important;
|
|
||||||
border-color: var(--primary) !important;
|
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;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--primary:hover {
|
.el-button--primary:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3) !important;
|
background: var(--primary-solid-hover) !important;
|
||||||
transform: translateY(-2px);
|
border-color: var(--primary-solid-hover) !important;
|
||||||
|
box-shadow: var(--shadow-primary-md) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 成功按钮 - 青绿 */
|
||||||
.el-button--success {
|
.el-button--success {
|
||||||
background: var(--gradient-cyan) !important;
|
background: var(--success) !important;
|
||||||
border-color: var(--cyan) !important;
|
border-color: var(--success) !important;
|
||||||
color: white !important;
|
color: var(--bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--success:hover {
|
.el-button--success:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3) !important;
|
background: #2DD4BF !important;
|
||||||
transform: translateY(-2px);
|
border-color: #2DD4BF !important;
|
||||||
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 警告按钮 - 橙黄 */
|
||||||
.el-button--warning {
|
.el-button--warning {
|
||||||
background: var(--gradient-yellow) !important;
|
background: var(--warning) !important;
|
||||||
border-color: var(--yellow) !important;
|
border-color: var(--warning) !important;
|
||||||
color: white !important;
|
color: var(--bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--warning:hover {
|
.el-button--warning:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3) !important;
|
background: #FBBF24 !important;
|
||||||
transform: translateY(-2px);
|
border-color: #FBBF24 !important;
|
||||||
|
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 危险按钮 - 粉红 */
|
||||||
.el-button--danger {
|
.el-button--danger {
|
||||||
background: var(--gradient-danger) !important;
|
background: var(--danger) !important;
|
||||||
border-color: var(--danger) !important;
|
border-color: var(--danger) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--danger:hover {
|
.el-button--danger:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3) !important;
|
background: #FCA5A5 !important;
|
||||||
transform: translateY(-2px);
|
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 {
|
.el-card {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-card__header {
|
||||||
border-bottom: 1px solid var(--border) !important;
|
border-bottom: 1px solid var(--border) !important;
|
||||||
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-card__body {
|
.el-card__body {
|
||||||
background: var(--bg-card) !important;
|
background: transparent !important;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 表格 -------------------- */
|
||||||
.el-table {
|
.el-table {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-table th.el-table__cell {
|
||||||
background: var(--bg-light) !important;
|
background: var(--surface-2) !important;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-secondary) !important;
|
||||||
border-bottom: 1px solid var(--border) !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 {
|
.el-table td.el-table__cell {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
border-bottom: 1px solid var(--border) !important;
|
border-bottom: 1px solid var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,16 +243,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-table tr:hover > td {
|
.el-table tr:hover > td {
|
||||||
background: #FFF0F5 !important;
|
background: var(--surface-2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__body tr.current-row > td.el-table__cell {
|
.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 {
|
.el-checkbox__inner {
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
background: white !important;
|
background: var(--surface-3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-checkbox__inner:hover {
|
.el-checkbox__inner:hover {
|
||||||
@@ -180,61 +271,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-checkbox__input.is-disabled .el-checkbox__inner {
|
.el-checkbox__input.is-disabled .el-checkbox__inner {
|
||||||
background: #f5f5f5 !important;
|
background: var(--el-disabled-bg) !important;
|
||||||
border-color: #e4e7ed !important;
|
border-color: var(--el-disabled-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 分页 -------------------- */
|
||||||
.el-pagination button {
|
.el-pagination button {
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
background: var(--bg-light) !important;
|
background: var(--surface) !important;
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pagination button:hover {
|
.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;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pagination li.is-active {
|
.el-pagination button:disabled {
|
||||||
background: var(--primary) !important;
|
background: var(--surface) !important;
|
||||||
color: white !important;
|
color: var(--text-muted) !important;
|
||||||
border-color: var(--primary) !important;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pager li {
|
.el-pager li {
|
||||||
background: var(--bg-light) !important;
|
background: var(--surface) !important;
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
border: 1px solid var(--border) !important;
|
border: 1px solid var(--border) !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pager li:hover {
|
.el-pager li:hover {
|
||||||
color: var(--primary) !important;
|
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 {
|
.el-tag--primary {
|
||||||
background: rgba(255, 107, 157, 0.1) !important;
|
background: var(--primary-soft) !important;
|
||||||
color: var(--primary) !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 {
|
.el-tag--success {
|
||||||
background: rgba(0, 212, 255, 0.1) !important;
|
background: var(--success-soft) !important;
|
||||||
color: var(--cyan) !important;
|
color: var(--success) !important;
|
||||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
border-color: rgba(34, 197, 94, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-tag--warning {
|
.el-tag--warning {
|
||||||
background: rgba(255, 184, 0, 0.1) !important;
|
background: var(--warning-soft) !important;
|
||||||
color: var(--yellow) !important;
|
color: var(--warning) !important;
|
||||||
border-color: rgba(255, 184, 0, 0.3) !important;
|
border-color: rgba(245, 158, 11, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-tag--danger {
|
.el-tag--danger {
|
||||||
background: rgba(255, 107, 107, 0.1) !important;
|
background: var(--danger-soft) !important;
|
||||||
color: var(--danger) !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 {
|
.el-rate__icon {
|
||||||
color: var(--border) !important;
|
color: var(--border) !important;
|
||||||
}
|
}
|
||||||
@@ -243,36 +359,54 @@
|
|||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 对话框 -------------------- */
|
||||||
.el-dialog {
|
.el-dialog {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-dialog__header {
|
||||||
border-bottom: 1px solid var(--border) !important;
|
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 {
|
.el-dialog__body {
|
||||||
background: white !important;
|
background: transparent !important;
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__footer {
|
.el-dialog__footer {
|
||||||
border-top: 1px solid var(--border) !important;
|
border-top: 1px solid var(--border) !important;
|
||||||
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 下拉菜单 -------------------- */
|
||||||
.el-dropdown-menu {
|
.el-dropdown-menu {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-dropdown-menu__item {
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dropdown-menu__item:hover {
|
.el-dropdown-menu__item:hover {
|
||||||
background: rgba(255, 107, 157, 0.1) !important;
|
background: var(--primary-soft) !important;
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 滚动条 -------------------- */
|
||||||
.el-scrollbar__wrap::-webkit-scrollbar {
|
.el-scrollbar__wrap::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -287,46 +421,54 @@
|
|||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 表单 -------------------- */
|
||||||
.el-form-item__label {
|
.el-form-item__label {
|
||||||
color: var(--text-muted) !important;
|
color: var(--text-secondary) !important;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-form-item__error {
|
.el-form-item__error {
|
||||||
color: var(--danger) !important;
|
color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 消息提示 -------------------- */
|
||||||
.el-message {
|
.el-message {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-message--success {
|
||||||
background: rgba(0, 212, 255, 0.1) !important;
|
background: var(--surface) !important;
|
||||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
border-color: var(--success) !important;
|
||||||
color: var(--cyan) !important;
|
color: var(--success) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message--error {
|
.el-message--error {
|
||||||
background: rgba(255, 107, 107, 0.1) !important;
|
background: var(--surface) !important;
|
||||||
border-color: rgba(255, 107, 107, 0.3) !important;
|
border-color: var(--danger) !important;
|
||||||
color: var(--danger) !important;
|
color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message--warning {
|
.el-message--warning {
|
||||||
background: rgba(255, 184, 0, 0.1) !important;
|
background: var(--surface) !important;
|
||||||
border-color: rgba(255, 184, 0, 0.3) !important;
|
border-color: var(--warning) !important;
|
||||||
color: var(--yellow) !important;
|
color: var(--warning) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message--info {
|
.el-message--info {
|
||||||
background: rgba(255, 107, 157, 0.1) !important;
|
background: var(--surface) !important;
|
||||||
border-color: rgba(255, 107, 157, 0.3) !important;
|
border-color: var(--primary) !important;
|
||||||
color: var(--primary) !important;
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 消息盒子 -------------------- */
|
||||||
.el-message-box {
|
.el-message-box {
|
||||||
border: 1px solid var(--border) !important;
|
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 {
|
.el-message-box__header {
|
||||||
@@ -334,33 +476,118 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box__title {
|
.el-message-box__title {
|
||||||
color: var(--primary) !important;
|
color: var(--text-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box__content {
|
.el-message-box__content {
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box__btns {
|
.el-message-box__btns {
|
||||||
border-top: 1px solid var(--border) !important;
|
border-top: 1px solid var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- 警告提示 -------------------- */
|
||||||
|
.el-alert {
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.el-alert--success {
|
.el-alert--success {
|
||||||
background-color: rgba(0, 255, 136, 0.1) !important;
|
background-color: var(--success-soft) !important;
|
||||||
border-color: var(--green) !important;
|
border: 1px solid rgba(34, 197, 94, 0.3) !important;
|
||||||
|
color: var(--success) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-alert--info {
|
.el-alert--info {
|
||||||
background-color: rgba(255, 107, 157, 0.1) !important;
|
background-color: var(--primary-soft) !important;
|
||||||
border-color: var(--primary) !important;
|
border: 1px solid rgba(146, 124, 255, 0.3) !important;
|
||||||
|
color: var(--primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-alert--warning {
|
.el-alert--warning {
|
||||||
background-color: rgba(255, 184, 0, 0.1) !important;
|
background-color: var(--warning-soft) !important;
|
||||||
border-color: var(--yellow) !important;
|
border: 1px solid rgba(245, 158, 11, 0.3) !important;
|
||||||
|
color: var(--warning) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-alert--error {
|
.el-alert--error {
|
||||||
background-color: rgba(255, 51, 102, 0.1) !important;
|
background-color: var(--danger-soft) !important;
|
||||||
border-color: var(--danger) !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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 工具类 CSS - 冷灰紫主题
|
||||||
|
* 提供通用的布局和样式工具类
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 卡片 ==================== */
|
||||||
.card-base {
|
.card-base {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--bg-card);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: var(--transition-hover);
|
transition: var(--transition-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-base:hover {
|
.card-base:hover {
|
||||||
box-shadow: var(--shadow-md);
|
border-color: var(--border-light);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -22,14 +26,15 @@
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 按钮工具类 ==================== */
|
||||||
.btn-base {
|
.btn-base {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -38,61 +43,87 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-base:hover {
|
||||||
background: var(--gradient-primary);
|
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主要按钮 - 深紫实心 */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-solid);
|
||||||
|
border-color: var(--primary-solid);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3);
|
background: var(--primary-solid-hover);
|
||||||
transform: translateY(-2px);
|
border-color: var(--primary-solid-hover);
|
||||||
|
box-shadow: var(--shadow-primary-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 成功按钮 */
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: var(--gradient-cyan);
|
background: var(--success);
|
||||||
border-color: var(--cyan);
|
border-color: var(--success);
|
||||||
color: white;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success:hover {
|
.btn-success:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
|
background: #2DD4BF;
|
||||||
transform: translateY(-2px);
|
border-color: #2DD4BF;
|
||||||
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 警告按钮 */
|
||||||
.btn-warning {
|
.btn-warning {
|
||||||
background: var(--gradient-yellow);
|
background: var(--warning);
|
||||||
border-color: var(--yellow);
|
border-color: var(--warning);
|
||||||
color: white;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-warning:hover {
|
.btn-warning:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3);
|
background: #FBBF24;
|
||||||
transform: translateY(-2px);
|
border-color: #FBBF24;
|
||||||
|
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 危险按钮 */
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--gradient-danger);
|
background: var(--danger);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
background: #FCA5A5;
|
||||||
transform: translateY(-2px);
|
border-color: #FCA5A5;
|
||||||
|
box-shadow: 0 0 20px rgba(251, 113, 133, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 布局 ==================== */
|
||||||
.page-container {
|
.page-container {
|
||||||
padding: 20px;
|
padding: 24px;
|
||||||
background: var(--bg-page);
|
background: var(--bg);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +135,7 @@
|
|||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +145,19 @@
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1200px) {
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex 工具类 */
|
||||||
.flex-center {
|
.flex-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -137,50 +175,103 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 文字颜色 ==================== */
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-cyan {
|
.text-accent {
|
||||||
color: var(--cyan);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-success {
|
.text-success {
|
||||||
color: var(--green);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-warning {
|
.text-warning {
|
||||||
color: var(--yellow);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-info {
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 背景 ==================== */
|
||||||
.bg-gradient-primary {
|
.bg-gradient-primary {
|
||||||
background: var(--gradient-primary);
|
background: var(--gradient-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-gradient-cyan {
|
.bg-gradient-accent {
|
||||||
background: var(--gradient-cyan);
|
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);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rounded-xl {
|
.border-light {
|
||||||
border-radius: var(--radius-xl);
|
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 {
|
.rounded-lg {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-xl {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 阴影 ==================== */
|
||||||
.shadow-sm {
|
.shadow-sm {
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
@@ -188,3 +279,156 @@
|
|||||||
.shadow-md {
|
.shadow-md {
|
||||||
box-shadow: var(--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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* CSS 变量定义 - 冷灰紫主题
|
||||||
|
* 设计理念:冷灰做信息底座,克制紫色做品牌识别和交互强调
|
||||||
|
* 参考:Material 3 颜色角色体系
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #FF6B9D;
|
/* ==================== 背景层次 (Surface Roles) ==================== */
|
||||||
--primary-light: #FF8FB3;
|
--bg: #0F1117; /* 最底层背景,接近黑但不是纯黑 */
|
||||||
--primary-dark: #FF5A8F;
|
--surface: #181C25; /* 卡片、表格、侧边栏 */
|
||||||
|
--surface-2: #1F2430; /* 悬停状态、次级面板 */
|
||||||
--cyan: #00D4FF;
|
--surface-3: #262C3A; /* 输入框、选中行背景 */
|
||||||
--cyan-light: #00E5FF;
|
--border: #2E3545; /* 边框,负责把结构切出来 */
|
||||||
--cyan-dark: #00B8E0;
|
--border-light: #3A4356; /* 稍亮的边框 */
|
||||||
|
|
||||||
--green: #34D399;
|
/* ==================== 文字颜色 ==================== */
|
||||||
--yellow: #FFB800;
|
--text-primary: #F5F7FA; /* 主要文字,对比度充足 */
|
||||||
--danger: #FF6B6B;
|
--text-secondary: #A5AEBD; /* 次要文字 */
|
||||||
--purple: #A855F7;
|
--text-muted: #7C8596; /* 弱化文字、placeholder */
|
||||||
|
|
||||||
--bg-page: #FAFAFA;
|
/* ==================== 品牌紫色系 (Brand Purple) ==================== */
|
||||||
--bg-card: #FFFFFF;
|
/* 亮紫:用于链接、选中、图标、焦点 - "发光"和识别 */
|
||||||
--bg-light: #FFF9FB;
|
--primary: #927CFF;
|
||||||
|
--primary-rgb: 146, 124, 255;
|
||||||
--text-primary: #333333;
|
--primary-hover: #A78BFA;
|
||||||
--text-secondary: #999999;
|
--primary-soft: #2A2442; /* 淡紫背景,用于选中态底色 */
|
||||||
--text-muted: #666666;
|
|
||||||
|
/* 深紫:用于实心按钮,配白字更稳 - "承载文字" */
|
||||||
--border: #FFE4EC;
|
--primary-solid: #6B4EFF;
|
||||||
--border-light: #FFD6E3;
|
--primary-solid-hover: #5B3DF5;
|
||||||
|
|
||||||
--gradient-primary: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%);
|
/* ==================== 辅助色 (Accent) ==================== */
|
||||||
--gradient-cyan: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%);
|
--accent: #2DD4BF; /* 青绿色,辅助强调 */
|
||||||
--gradient-yellow: linear-gradient(135deg, #FFB800 0%, #FFD000 100%);
|
--accent-rgb: 45, 212, 191;
|
||||||
--gradient-danger: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%);
|
--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-sm: 6px;
|
||||||
--radius-md: 8px;
|
--radius-md: 10px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--radius-xl: 16px;
|
--radius-xl: 14px;
|
||||||
--radius-2xl: 20px;
|
--radius-2xl: 16px;
|
||||||
|
|
||||||
--shadow-sm: 0 2px 8px rgba(255, 107, 157, 0.08);
|
/* ==================== 阴影 (克制使用,更多靠层级和边框) ==================== */
|
||||||
--shadow-md: 0 4px 12px rgba(255, 107, 157, 0.15);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 20px rgba(255, 107, 157, 0.2);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||||
--transition-base: all 0.3s ease;
|
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
|
||||||
--transition-hover: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
/* 紫色光晕 - 用于焦点态 */
|
||||||
|
--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;
|
||||||
}
|
}
|
||||||
|
|||||||
63
frontend/src/utils/clipboard.js
Normal file
63
frontend/src/utils/clipboard.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
84
frontend/src/utils/confirm.js
Normal file
84
frontend/src/utils/confirm.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认对话框工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 默认配置 */
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示确认对话框
|
||||||
|
* @param {string} message
|
||||||
|
* @param {string} [title]
|
||||||
|
* @param {object} [config]
|
||||||
|
* @returns {Promise<boolean>} 用户点击确定返回 true,取消返回 false
|
||||||
|
*/
|
||||||
|
export async function confirm(message, title = '提示', config = {}) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(message, title, {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示删除确认对话框
|
||||||
|
* @param {string} message
|
||||||
|
* @param {string} [itemName] - 要删除的项目名称
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function confirmDelete(message, itemName = '') {
|
||||||
|
const fullMessage = itemName
|
||||||
|
? `确定要删除${itemName}吗?此操作不可恢复。`
|
||||||
|
: message
|
||||||
|
|
||||||
|
return confirm(fullMessage, '删除确认', {
|
||||||
|
confirmButtonText: '删除吧~',
|
||||||
|
cancelButtonText: '再等等',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示批量删除确认对话框
|
||||||
|
* @param {number} count
|
||||||
|
* @param {string} [itemName]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function confirmBatchDelete(count, itemName = '项') {
|
||||||
|
return confirm(
|
||||||
|
`确定要删除选中的 ${count} 个${itemName}吗?`,
|
||||||
|
'批量删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '删除吧~',
|
||||||
|
cancelButtonText: '再等等',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示清空确认对话框
|
||||||
|
* @param {string} [target]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function confirmClear(target = '所有数据') {
|
||||||
|
return confirm(
|
||||||
|
`确定要清空${target}吗?此操作不可恢复。`,
|
||||||
|
'清空确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '清空吧~',
|
||||||
|
cancelButtonText: '再等等',
|
||||||
|
type: 'danger'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/utils/format.js
Normal file
108
frontend/src/utils/format.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 格式化工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期时间
|
||||||
|
* @param {string|Date|number} dateTimeStr
|
||||||
|
* @param {string} [fallback] - 无效日期时的回退文本
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDateTime(dateTimeStr, fallback = '-') {
|
||||||
|
if (!dateTimeStr) return fallback
|
||||||
|
|
||||||
|
const date = new Date(dateTimeStr)
|
||||||
|
if (isNaN(date.getTime())) return fallback
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = pad(date.getMonth() + 1)
|
||||||
|
const day = pad(date.getDate())
|
||||||
|
const hours = pad(date.getHours())
|
||||||
|
const minutes = pad(date.getMinutes())
|
||||||
|
const seconds = pad(date.getSeconds())
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间(简化版)
|
||||||
|
* @param {string|Date|number} timeStr
|
||||||
|
* @param {string} [fallback]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatTime(timeStr, fallback = '-') {
|
||||||
|
if (!timeStr) return fallback
|
||||||
|
|
||||||
|
const date = new Date(timeStr)
|
||||||
|
if (isNaN(date.getTime())) return fallback
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化数字(添加千分位)
|
||||||
|
* @param {number} num
|
||||||
|
* @param {number} [decimals] - 小数位数
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatNumber(num, decimals = 0) {
|
||||||
|
if (typeof num !== 'number' || isNaN(num)) return '-'
|
||||||
|
return num.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化百分比
|
||||||
|
* @param {number} value
|
||||||
|
* @param {number} [total]
|
||||||
|
* @param {number} [decimals]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatPercent(value, total, decimals = 1) {
|
||||||
|
if (total !== undefined) {
|
||||||
|
if (!total) return '0%'
|
||||||
|
value = (value / total) * 100
|
||||||
|
}
|
||||||
|
return `${value.toFixed(decimals)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
* @param {number} bytes
|
||||||
|
* @param {number} [decimals]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes, decimals = 2) {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时长(秒转可读文本)
|
||||||
|
* @param {number} seconds
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (!seconds || seconds < 0) return '-'
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${minutes}分${secs}秒`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分${secs}秒`
|
||||||
|
} else {
|
||||||
|
return `${secs}秒`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -1,133 +1,173 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<PageHeader title="代理池管理系统" icon="🔮" />
|
<PageHeader title="代理池管理系统" :icon="MagicStick" />
|
||||||
|
|
||||||
<el-row :gutter="20" class="stats-row">
|
<el-row :gutter="20" class="stats-row">
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||||
<StatCard type="total" icon="📊" :value="stats.total || 0" label="总代理数" />
|
<StatCard
|
||||||
|
type="total"
|
||||||
|
:icon="DataLine"
|
||||||
|
:value="stats.total || 0"
|
||||||
|
label="总代理数"
|
||||||
|
/>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||||
<StatCard type="available" icon="✨" :value="stats.available || 0" label="可用数量" />
|
<StatCard
|
||||||
|
type="available"
|
||||||
|
:icon="CircleCheck"
|
||||||
|
:value="stats.available || 0"
|
||||||
|
label="可用数量"
|
||||||
|
/>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||||
<StatCard type="new" icon="🎉" :value="stats.today_new || 0" label="今日新增" />
|
<StatCard
|
||||||
|
type="new"
|
||||||
|
:icon="Timer"
|
||||||
|
:value="stats.today_new || 0"
|
||||||
|
label="今日新增"
|
||||||
|
/>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
|
||||||
<StatCard type="score" icon="⭐" :value="(stats.avg_score || 0).toFixed(1)" label="平均分数" />
|
<StatCard
|
||||||
|
type="score"
|
||||||
|
:icon="StarFilled"
|
||||||
|
:value="avgScore"
|
||||||
|
label="平均分数"
|
||||||
|
/>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-row :gutter="20" class="charts-row">
|
<el-row :gutter="20" class="charts-row">
|
||||||
<el-col :span="16">
|
<el-col :xs="24" :lg="16">
|
||||||
<ProtocolChart :data="stats" />
|
<ProtocolChart :data="stats" />
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8">
|
<el-col :xs="24" :lg="8">
|
||||||
<QuickActions :loading="crawler.running" @start-crawler="handleStartCrawler" @export="handleExport" @clean="handleClean" />
|
<QuickActions
|
||||||
|
@export="handleExport"
|
||||||
|
@clean="handleClean"
|
||||||
|
/>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-card class="status-card" shadow="hover" v-if="crawler.running">
|
<!-- 系统状态 -->
|
||||||
<template #header>
|
<el-row :gutter="20" class="status-row">
|
||||||
<div class="card-header">
|
<el-col :xs="24">
|
||||||
<span class="card-title">🔄 当前任务状态</span>
|
<el-card class="status-card" shadow="hover">
|
||||||
</div>
|
<template #header>
|
||||||
</template>
|
<div class="card-header">
|
||||||
<div class="status-content">
|
<span class="card-title">
|
||||||
<el-progress
|
<el-icon><InfoFilled /></el-icon>
|
||||||
:percentage="progressPercentage"
|
系统状态
|
||||||
:stroke-width="20"
|
</span>
|
||||||
class="progress-bar"
|
</div>
|
||||||
>
|
</template>
|
||||||
<span class="progress-text">
|
<div class="status-list">
|
||||||
发现 {{ crawler.progress.found }} 个,验证通过 {{ crawler.progress.verified }} 个,成功率 {{ crawler.progress.success_rate }}%
|
<div class="status-item">
|
||||||
</span>
|
<span class="status-label">验证调度器</span>
|
||||||
</el-progress>
|
<el-tag :type="stats.scheduler_running ? 'success' : 'info'" size="large">
|
||||||
<div class="status-message">{{ crawler.statusMessage }}</div>
|
{{ stats.scheduler_running ? '运行中' : '已停止' }}
|
||||||
</div>
|
</el-tag>
|
||||||
</el-card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
MagicStick,
|
||||||
|
DataLine,
|
||||||
|
CircleCheck,
|
||||||
|
Timer,
|
||||||
|
StarFilled,
|
||||||
|
InfoFilled
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
import { useProxyStore } from '../stores/proxy'
|
import { useProxyStore } from '../stores/proxy'
|
||||||
import { useCrawlerStore } from '../stores/crawler'
|
import { formatNumber } from '../utils/format'
|
||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import ProtocolChart from '../components/ProtocolChart.vue'
|
import ProtocolChart from '../components/ProtocolChart.vue'
|
||||||
import QuickActions from '../components/QuickActions.vue'
|
import QuickActions from '../components/QuickActions.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
|
||||||
|
// ==================== Store ====================
|
||||||
const proxyStore = useProxyStore()
|
const proxyStore = useProxyStore()
|
||||||
const crawler = useCrawlerStore()
|
|
||||||
|
|
||||||
|
// ==================== 计算属性 ====================
|
||||||
const stats = computed(() => proxyStore.stats)
|
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) {
|
const REFRESH_INTERVAL = 5000
|
||||||
await proxyStore.fetchStats()
|
|
||||||
initCharts()
|
|
||||||
ElMessage.success('任务完成,数据已更新~')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const progressPercentage = computed(() => {
|
|
||||||
if (crawler.progress.total === 0) return 0
|
|
||||||
return Math.round((crawler.progress.current / crawler.progress.total) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
let refreshTimer = null
|
let refreshTimer = null
|
||||||
|
let isPageVisible = true
|
||||||
|
|
||||||
async function handleStartCrawler() {
|
function handleVisibilityChange() {
|
||||||
try {
|
isPageVisible = !document.hidden
|
||||||
await ElMessageBox.confirm('确定要开始爬取代理吗?这可能需要一些时间哦~', '提示', {
|
if (isPageVisible) {
|
||||||
confirmButtonText: '开始吧~',
|
refreshData()
|
||||||
cancelButtonText: '再等等',
|
|
||||||
type: 'info'
|
|
||||||
})
|
|
||||||
|
|
||||||
const success = await crawler.startCrawler(50)
|
|
||||||
if (success) {
|
|
||||||
ElMessage.success('爬虫任务开始啦~')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
const success = await proxyStore.exportProxies('txt')
|
|
||||||
if (success) {
|
|
||||||
ElMessage.success('代理导出成功啦~')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleClean() {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定要清理所有无效代理吗?', '提示', {
|
|
||||||
confirmButtonText: '清理吧~',
|
|
||||||
cancelButtonText: '再等等',
|
|
||||||
type: 'warning'
|
|
||||||
})
|
|
||||||
|
|
||||||
const deletedCount = await proxyStore.cleanInvalidProxies()
|
|
||||||
if (deletedCount >= 0) {
|
|
||||||
ElMessage.success(`清理了 ${deletedCount} 个无效代理啦~`)
|
|
||||||
await proxyStore.fetchStats()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
await proxyStore.fetchStats()
|
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 () => {
|
onMounted(async () => {
|
||||||
await refreshData()
|
await refreshData()
|
||||||
crawler.connectWebSocket()
|
|
||||||
|
|
||||||
refreshTimer = setInterval(refreshData, 5000)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (isPageVisible) {
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}, REFRESH_INTERVAL)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -135,7 +175,7 @@ onUnmounted(() => {
|
|||||||
clearInterval(refreshTimer)
|
clearInterval(refreshTimer)
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
}
|
}
|
||||||
crawler.disconnectWebSocket()
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -148,42 +188,62 @@ onUnmounted(() => {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
margin-bottom: 20px;
|
background: var(--surface);
|
||||||
backdrop-filter: blur(10px);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card:hover {
|
.card-header {
|
||||||
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;
|
|
||||||
font-size: 16px;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,55 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<PageHeader title="插件管理" icon="🔌" />
|
<PageHeader title="插件管理" :icon="Connection" />
|
||||||
|
|
||||||
<el-card class="plugins-card" shadow="hover" v-loading="pluginsStore.loading">
|
<el-card class="plugins-card" shadow="hover" v-loading="pluginsStore.loading">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">📦 插件列表</span>
|
<div class="header-left">
|
||||||
<el-button type="primary" @click="handleRefresh" size="large">
|
<span class="card-title">
|
||||||
<span class="btn-icon">🔄</span>
|
<el-icon class="header-icon"><Box /></el-icon>
|
||||||
刷新列表
|
插件列表
|
||||||
</el-button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table :data="pluginsStore.plugins" stripe>
|
<el-table :data="pluginsStore.plugins">
|
||||||
<el-table-column prop="name" label="插件名称" width="200">
|
<el-table-column prop="name" label="插件名称" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="plugin-name">
|
<div class="plugin-name">
|
||||||
<span class="plugin-icon">🔌</span>
|
<el-icon class="plugin-icon"><Connection /></el-icon>
|
||||||
<span>{{ row.name }}</span>
|
<span class="plugin-name-text">{{ row.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="description" label="描述" min-width="200">
|
<el-table-column prop="description" label="描述" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="plugin-description">{{ row.description }}</span>
|
<span class="plugin-description">{{ row.description || '暂无描述' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="状态" width="120">
|
<el-table-column label="状态" width="120" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="row.enabled"
|
v-model="row.enabled"
|
||||||
@change="(val) => handleToggle(row.id, val)"
|
@change="(val) => handleToggle(row.id, val)"
|
||||||
active-color="#FF6B9D"
|
class="theme-switch"
|
||||||
inactive-color="#dcdfe6"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="统计" width="200">
|
<el-table-column label="统计" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="plugin-stats">
|
<div class="plugin-stats">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">成功</span>
|
<el-icon class="stat-icon success"><CircleCheck /></el-icon>
|
||||||
<span class="stat-value success">{{ row.success_count }}</span>
|
<span class="stat-value success">{{ row.success_count || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">失败</span>
|
<el-icon class="stat-icon failed"><CircleClose /></el-icon>
|
||||||
<span class="stat-value failed">{{ row.failure_count }}</span>
|
<span class="stat-value failed">{{ row.failure_count || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,49 +74,91 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleCrawl(row.id)"
|
@click="handleCrawl(row.id)"
|
||||||
:loading="crawlingPlugin === row.id"
|
:loading="crawlingPlugin === row.id"
|
||||||
|
:disabled="!row.enabled"
|
||||||
>
|
>
|
||||||
<span class="btn-icon">🚀</span>
|
<el-icon class="btn-icon"><Promotion /></el-icon>
|
||||||
立即爬取
|
爬取
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
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 { usePluginsStore } from '../stores/plugins'
|
||||||
|
import { pluginsAPI } from '../api'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
|
||||||
const pluginsStore = usePluginsStore()
|
const pluginsStore = usePluginsStore()
|
||||||
const crawlingPlugin = ref(null)
|
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() {
|
async function handleRefresh() {
|
||||||
await pluginsStore.fetchPlugins()
|
await pluginsStore.fetchPlugins()
|
||||||
ElMessage.success('插件列表已刷新~')
|
ElMessage.success('插件列表已刷新')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle(pluginId, enabled) {
|
async function handleToggle(pluginId, enabled) {
|
||||||
const success = await pluginsStore.togglePlugin(pluginId, enabled)
|
const success = await pluginsStore.togglePlugin(pluginId, enabled)
|
||||||
if (success) {
|
if (success) {
|
||||||
ElMessage.success(enabled ? '插件已启用~' : '插件已禁用~')
|
ElMessage.success(enabled ? '插件已启用' : '插件已禁用')
|
||||||
} else {
|
} else {
|
||||||
|
// 失败时刷新列表恢复状态
|
||||||
await pluginsStore.fetchPlugins()
|
await pluginsStore.fetchPlugins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,15 +166,87 @@ async function handleToggle(pluginId, enabled) {
|
|||||||
async function handleCrawl(pluginId) {
|
async function handleCrawl(pluginId) {
|
||||||
try {
|
try {
|
||||||
crawlingPlugin.value = pluginId
|
crawlingPlugin.value = pluginId
|
||||||
const success = await pluginsStore.crawlPlugin(pluginId)
|
lastCrawlResult.value = null
|
||||||
if (success) {
|
|
||||||
ElMessage.success('插件开始爬取啦~')
|
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 {
|
} finally {
|
||||||
crawlingPlugin.value = null
|
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 () => {
|
onMounted(async () => {
|
||||||
await pluginsStore.fetchPlugins()
|
await pluginsStore.fetchPlugins()
|
||||||
})
|
})
|
||||||
@@ -127,21 +254,41 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.plugins-card {
|
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.header-actions {
|
||||||
font-size: 18px;
|
display: flex;
|
||||||
font-weight: 600;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
margin-right: 8px;
|
||||||
color: var(--primary);
|
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 {
|
.plugin-name {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -149,12 +296,22 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plugin-icon {
|
.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 {
|
.plugin-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-stats {
|
.plugin-stats {
|
||||||
@@ -164,13 +321,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-icon {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary);
|
}
|
||||||
|
|
||||||
|
.stat-icon.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.failed {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
@@ -179,7 +343,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.success {
|
.stat-value.success {
|
||||||
color: var(--green);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.failed {
|
.stat-value.failed {
|
||||||
@@ -187,12 +351,26 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.last-run {
|
.last-run {
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.crawl-result {
|
||||||
font-size: 20px;
|
margin-top: 16px;
|
||||||
margin-right: 4px;
|
}
|
||||||
|
|
||||||
|
.crawl-stats {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid-count {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-count {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<PageHeader title="代理列表" icon="📋" />
|
<PageHeader title="代理列表" :icon="Document" />
|
||||||
|
|
||||||
<el-card class="filter-card" shadow="hover">
|
<el-card class="filter-card" shadow="hover">
|
||||||
<el-form :inline="true" :model="filterForm" class="form-row">
|
<el-form :inline="true" :model="filterForm" class="form-row">
|
||||||
<el-form-item label="协议类型">
|
<el-form-item label="协议类型">
|
||||||
<el-select v-model="filterForm.protocol" placeholder="全部" clearable style="width: 120px" @change="handleSearch">
|
<el-select
|
||||||
<el-option label="全部" value=""></el-option>
|
v-model="filterForm.protocol"
|
||||||
<el-option label="HTTP" value="http"></el-option>
|
placeholder="全部"
|
||||||
<el-option label="HTTPS" value="https"></el-option>
|
clearable
|
||||||
<el-option label="SOCKS4" value="socks4"></el-option>
|
style="width: 120px"
|
||||||
<el-option label="SOCKS5" value="socks5"></el-option>
|
@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-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="最低分数">
|
<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>
|
||||||
<el-form-item label="排序方式">
|
<el-form-item label="排序方式">
|
||||||
<el-select v-model="filterForm.sortBy" style="width: 140px" @change="handleSearch">
|
<el-select v-model="filterForm.sortBy" style="width: 140px" @change="handleSearch">
|
||||||
<el-option label="更新时间" value="last_check"></el-option>
|
<el-option label="更新时间" value="last_check" />
|
||||||
<el-option label="分数" value="score"></el-option>
|
<el-option label="分数" value="score" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -28,16 +40,25 @@
|
|||||||
<el-card class="table-card" shadow="hover">
|
<el-card class="table-card" shadow="hover">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-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">
|
<div class="header-actions">
|
||||||
<el-button-group>
|
<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-button>
|
||||||
<el-dropdown trigger="click" @command="handleExport">
|
<el-dropdown trigger="click" @command="handleExport">
|
||||||
<el-button type="success">
|
<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>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
@@ -58,41 +79,46 @@
|
|||||||
v-loading="proxyStore.loading"
|
v-loading="proxyStore.loading"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
:row-style="{ cursor: 'pointer' }"
|
:row-style="{ cursor: 'pointer' }"
|
||||||
|
empty-text="暂无数据"
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="55" />
|
<el-table-column type="selection" width="55" />
|
||||||
<el-table-column prop="ip" label="IP地址" width="150" />
|
<el-table-column prop="ip" label="IP地址" width="150" />
|
||||||
<el-table-column prop="port" label="端口" width="100" />
|
<el-table-column prop="port" label="端口" width="100" />
|
||||||
<el-table-column prop="protocol" label="协议" width="100">
|
<el-table-column prop="protocol" label="协议" width="100">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getProtocolType(scope.row.protocol)" effect="light">
|
<el-tag :type="getProtocolType(row.protocol)" effect="light" size="small">
|
||||||
{{ scope.row.protocol.toUpperCase() }}
|
{{ row.protocol.toUpperCase() }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="score" label="分数" width="100">
|
<el-table-column prop="score" label="分数" width="100">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<span class="score-value">{{ scope.row.score || 0 }}</span>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="last_check" label="最后检查时间">
|
<el-table-column prop="last_check" label="最后检查时间" min-width="180">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
{{ formatDateTime(scope.row.last_check) }}
|
{{ formatDateTime(row.last_check) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click.stop="handleCopy(scope.row)"
|
@click.stop="handleCopy(row)"
|
||||||
>
|
>
|
||||||
|
<el-icon class="btn-icon"><CopyDocument /></el-icon>
|
||||||
复制
|
复制
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@click.stop="handleDelete(scope.row)"
|
@click.stop="handleDelete(row)"
|
||||||
>
|
>
|
||||||
|
<el-icon class="btn-icon"><Delete /></el-icon>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -103,7 +129,7 @@
|
|||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="currentPage"
|
v-model:current-page="currentPage"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="PAGE_SIZE_OPTIONS"
|
||||||
:total="proxyStore.total"
|
:total="proxyStore.total"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@size-change="handleSizeChange"
|
@size-change="handleSizeChange"
|
||||||
@@ -115,19 +141,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowDown } from '@element-plus/icons-vue'
|
import { Document, ArrowDown, List, Delete, Download, CopyDocument } from '@element-plus/icons-vue'
|
||||||
import { useProxyStore } from '../stores/proxy'
|
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'
|
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 currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||||
const selectedProxies = ref([])
|
const selectedProxies = ref([])
|
||||||
|
let abortController = null
|
||||||
|
|
||||||
const filterForm = reactive({
|
const filterForm = reactive({
|
||||||
protocol: '',
|
protocol: '',
|
||||||
@@ -136,29 +170,26 @@ const filterForm = reactive({
|
|||||||
sortOrder: 'DESC'
|
sortOrder: 'DESC'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== 协议类型映射 ====================
|
||||||
|
const PROTOCOL_TYPE_MAP = {
|
||||||
|
http: 'info',
|
||||||
|
https: 'success',
|
||||||
|
socks4: 'warning',
|
||||||
|
socks5: 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
function getProtocolType(protocol) {
|
function getProtocolType(protocol) {
|
||||||
const types = {
|
return PROTOCOL_TYPE_MAP[protocol] || 'info'
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 数据获取 ====================
|
||||||
async function fetchProxies() {
|
async function fetchProxies() {
|
||||||
|
// 取消之前的请求
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
await proxyStore.fetchProxies({
|
await proxyStore.fetchProxies({
|
||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
page_size: pageSize.value,
|
page_size: pageSize.value,
|
||||||
@@ -166,75 +197,55 @@ async function fetchProxies() {
|
|||||||
min_score: filterForm.minScore,
|
min_score: filterForm.minScore,
|
||||||
sort_by: filterForm.sortBy,
|
sort_by: filterForm.sortBy,
|
||||||
sort_order: filterForm.sortOrder
|
sort_order: filterForm.sortOrder
|
||||||
})
|
}, abortController.signal)
|
||||||
|
|
||||||
|
abortController = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 事件处理 ====================
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
fetchProxies()
|
fetchProxies()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
filterForm.protocol = ''
|
|
||||||
filterForm.minScore = 0
|
|
||||||
filterForm.sortBy = 'last_check'
|
|
||||||
currentPage.value = 1
|
|
||||||
fetchProxies()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectionChange(selection) {
|
function handleSelectionChange(selection) {
|
||||||
selectedProxies.value = selection.map(item => [item.ip, item.port])
|
selectedProxies.value = selection.map(item => [item.ip, item.port])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy(proxy) {
|
async function handleCopy(proxy) {
|
||||||
const text = `${proxy.ip}:${proxy.port}`
|
await copyProxy(proxy)
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
ElMessage.success(`已复制 ${text} 到剪贴板啦~`)
|
|
||||||
} catch {
|
|
||||||
ElMessage.error('复制失败呢~')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(proxy) {
|
async function handleDelete(proxy) {
|
||||||
try {
|
const confirmed = await confirmDelete(`代理 ${proxy.ip}:${proxy.port}`)
|
||||||
await ElMessageBox.confirm(`确定要删除代理 ${proxy.ip}:${proxy.port} 吗?`, '提示', {
|
if (!confirmed) return
|
||||||
confirmButtonText: '删除吧~',
|
|
||||||
cancelButtonText: '再等等',
|
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
|
||||||
type: 'warning'
|
if (success) {
|
||||||
})
|
ElMessage.success('删除成功')
|
||||||
|
fetchProxies()
|
||||||
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
|
|
||||||
if (success) {
|
|
||||||
ElMessage.success('删除成功啦~')
|
|
||||||
fetchProxies()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBatchDelete() {
|
async function handleBatchDelete() {
|
||||||
try {
|
const count = selectedProxies.value.length
|
||||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedProxies.value.length} 个代理吗?`, '提示', {
|
if (!count) return
|
||||||
confirmButtonText: '删除吧~',
|
|
||||||
cancelButtonText: '再等等',
|
const confirmed = await confirmBatchDelete(count, '代理')
|
||||||
type: 'warning'
|
if (!confirmed) return
|
||||||
})
|
|
||||||
|
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
|
||||||
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
|
if (deletedCount > 0) {
|
||||||
if (deletedCount > 0) {
|
ElMessage.success(`已删除 ${deletedCount} 个代理`)
|
||||||
ElMessage.success(`批量删除成功啦~共删除了 ${deletedCount} 个代理`)
|
selectedProxies.value = []
|
||||||
selectedProxies.value = []
|
fetchProxies()
|
||||||
fetchProxies()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport(format) {
|
async function handleExport(format) {
|
||||||
const success = await proxyStore.exportProxies(format, filterForm.protocol || null)
|
const success = await proxyStore.exportProxies(format, filterForm.protocol || null)
|
||||||
if (success) {
|
if (success) {
|
||||||
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功啦~`)
|
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,29 +260,40 @@ function handleCurrentChange(page) {
|
|||||||
fetchProxies()
|
fetchProxies()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProxies()
|
fetchProxies()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-card {
|
.filter-card {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
.table-card {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.table-card:hover,
|
||||||
display: flex;
|
.filter-card:hover {
|
||||||
justify-content: space-between;
|
border-color: var(--border-light);
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.header-icon {
|
||||||
font-size: 18px;
|
margin-right: 8px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,15 +302,28 @@ onMounted(() => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.score-value {
|
||||||
font-size: 20px;
|
font-weight: 600;
|
||||||
margin-right: 0;
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-high {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-medium {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-low {
|
||||||
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-wrapper {
|
.pagination-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 20px;
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,35 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<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">
|
<el-card class="settings-card" shadow="hover" v-loading="loading">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">🎨 基础配置</span>
|
<span class="card-title">
|
||||||
<el-button type="primary" @click="handleSave" size="large" :loading="saving">
|
<el-icon class="header-icon"><Tools /></el-icon>
|
||||||
<span class="btn-icon">💾</span>
|
基础配置
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleSave"
|
||||||
|
size="large"
|
||||||
|
:loading="saving"
|
||||||
|
>
|
||||||
|
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
|
||||||
保存配置
|
保存配置
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-form :model="settings" label-width="150px" class="settings-form">
|
<el-form
|
||||||
<el-form-item label="管理员API Key">
|
:model="settings"
|
||||||
<el-input
|
label-width="180px"
|
||||||
v-model="settings.api_key"
|
class="settings-form"
|
||||||
placeholder="请输入管理员API Key"
|
:rules="formRules"
|
||||||
type="password"
|
ref="formRef"
|
||||||
show-password
|
>
|
||||||
class="setting-input"
|
<el-divider content-position="left">爬虫配置</el-divider>
|
||||||
/>
|
|
||||||
<div class="setting-hint">用于执行管理操作的API Key</div>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="数据库路径">
|
<el-form-item label="爬取超时" prop="crawl_timeout">
|
||||||
<el-input v-model="settings.db_path" placeholder="数据库文件路径" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="爬取超时">
|
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="settings.crawl_timeout"
|
v-model="settings.crawl_timeout"
|
||||||
:min="5"
|
:min="5"
|
||||||
@@ -40,7 +98,18 @@
|
|||||||
<span class="setting-suffix">秒</span>
|
<span class="setting-suffix">秒</span>
|
||||||
</el-form-item>
|
</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
|
<el-input-number
|
||||||
v-model="settings.validation_timeout"
|
v-model="settings.validation_timeout"
|
||||||
:min="3"
|
:min="3"
|
||||||
@@ -51,16 +120,7 @@
|
|||||||
<span class="setting-suffix">秒</span>
|
<span class="setting-suffix">秒</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="最大重试次数">
|
<el-form-item label="验证并发数" prop="default_concurrency">
|
||||||
<el-input-number
|
|
||||||
v-model="settings.max_retries"
|
|
||||||
:min="0"
|
|
||||||
:max="10"
|
|
||||||
class="setting-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="默认并发数">
|
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="settings.default_concurrency"
|
v-model="settings.default_concurrency"
|
||||||
:min="10"
|
:min="10"
|
||||||
@@ -70,17 +130,39 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="最低代理分数">
|
<el-form-item label="自动验证间隔" prop="validate_interval_minutes">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="settings.min_proxy_score"
|
v-model="settings.validate_interval_minutes"
|
||||||
:min="0"
|
:min="5"
|
||||||
:max="10"
|
:max="1440"
|
||||||
:step="1"
|
:step="5"
|
||||||
class="setting-input"
|
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>
|
||||||
|
|
||||||
<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
|
<el-input-number
|
||||||
v-model="settings.proxy_expiry_days"
|
v-model="settings.proxy_expiry_days"
|
||||||
:min="1"
|
:min="1"
|
||||||
@@ -89,6 +171,7 @@
|
|||||||
class="setting-input"
|
class="setting-input"
|
||||||
/>
|
/>
|
||||||
<span class="setting-suffix">天</span>
|
<span class="setting-suffix">天</span>
|
||||||
|
<span class="setting-hint">超过此时间未验证的代理将被清理</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -96,73 +179,207 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
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'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
|
||||||
|
// ==================== 状态 ====================
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = 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({
|
const settings = reactive({
|
||||||
api_key: '',
|
|
||||||
db_path: '',
|
|
||||||
crawl_timeout: 30,
|
crawl_timeout: 30,
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
default_concurrency: 50,
|
default_concurrency: 50,
|
||||||
min_proxy_score: 5,
|
min_proxy_score: 0,
|
||||||
proxy_expiry_days: 7
|
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() {
|
async function fetchSettings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:8923/api/settings')
|
const response = await settingsAPI.getSettings()
|
||||||
if (response.ok) {
|
if (response.code === 200) {
|
||||||
const data = await response.json()
|
Object.assign(settings, response.data)
|
||||||
Object.assign(settings, data)
|
|
||||||
}
|
}
|
||||||
settings.api_key = localStorage.getItem('api_key') || ''
|
} catch (error) {
|
||||||
|
console.error('获取设置失败:', error)
|
||||||
|
ElMessage.error('获取设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
async function handleSave() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (settings.api_key) {
|
const response = await settingsAPI.saveSettings(settings)
|
||||||
localStorage.setItem('api_key', settings.api_key)
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('api_key')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { api_key, ...settingsToSend } = settings
|
if (response.code === 200) {
|
||||||
const response = await fetch('http://localhost:8923/api/settings', {
|
ElMessage.success('配置保存成功')
|
||||||
method: 'POST',
|
// 刷新调度器状态
|
||||||
headers: {
|
await fetchSchedulerStatus()
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(settingsToSend)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
ElMessage.success('配置保存成功啦~')
|
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('配置保存失败呢~')
|
ElMessage.error('配置保存失败')
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存设置失败:', error)
|
||||||
|
ElMessage.error('配置保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
|
fetchSchedulerStatus()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-card {
|
.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 {
|
.card-header {
|
||||||
@@ -172,13 +389,49 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
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 {
|
.settings-form {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +441,29 @@ onMounted(() => {
|
|||||||
|
|
||||||
.setting-suffix {
|
.setting-suffix {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-hint {
|
.setting-hint {
|
||||||
margin-top: 8px;
|
margin-left: 10px;
|
||||||
font-size: 12px;
|
color: var(--text-muted);
|
||||||
color: var(--text-secondary);
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
:deep(.el-form-item__label) {
|
||||||
font-size: 20px;
|
color: var(--text-secondary);
|
||||||
margin-right: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,30 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from core.crawler import BasePlugin
|
from core.crawler import BasePlugin
|
||||||
from core.log import logger
|
from core.log import logger
|
||||||
@@ -29,6 +29,11 @@ class Fate0Plugin(BasePlugin):
|
|||||||
port = data.get('port')
|
port = data.get('port')
|
||||||
protocol = data.get('type', 'http')
|
protocol = data.get('type', 'http')
|
||||||
|
|
||||||
|
# 协议标准化
|
||||||
|
protocol = protocol.lower().strip()
|
||||||
|
if protocol not in ('http', 'https', 'socks4', 'socks5'):
|
||||||
|
protocol = 'http'
|
||||||
|
|
||||||
if ip and port:
|
if ip and port:
|
||||||
yield ip, int(port), protocol
|
yield ip, int(port), protocol
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ class ProxyListDownloadPlugin(BasePlugin):
|
|||||||
self.name = "ProxyListDownload"
|
self.name = "ProxyListDownload"
|
||||||
self.urls = [
|
self.urls = [
|
||||||
"https://www.proxy-list.download/api/v1/get?type=http",
|
"https://www.proxy-list.download/api/v1/get?type=http",
|
||||||
"https://www.proxy-list.download/api/v1/get?type=https"
|
"https://www.proxy-list.download/api/v1/get?type=https",
|
||||||
|
"https://www.proxy-list.download/api/v1/get?type=socks4",
|
||||||
|
"https://www.proxy-list.download/api/v1/get?type=socks5"
|
||||||
]
|
]
|
||||||
|
|
||||||
async def parse(self, html):
|
async def parse(self, html):
|
||||||
@@ -24,6 +26,16 @@ class ProxyListDownloadPlugin(BasePlugin):
|
|||||||
lines = html.split('\n')
|
lines = html.split('\n')
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
# 根据 URL 判断协议类型
|
||||||
|
if 'type=socks4' in self.current_url:
|
||||||
|
protocol = 'socks4'
|
||||||
|
elif 'type=socks5' in self.current_url:
|
||||||
|
protocol = 'socks5'
|
||||||
|
elif 'type=https' in self.current_url:
|
||||||
|
protocol = 'https'
|
||||||
|
else:
|
||||||
|
protocol = 'http'
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -34,7 +46,6 @@ class ProxyListDownloadPlugin(BasePlugin):
|
|||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
ip = parts[0]
|
ip = parts[0]
|
||||||
port = parts[1]
|
port = parts[1]
|
||||||
protocol = 'http' if 'type=http' in self.current_url else 'https'
|
|
||||||
yield ip, int(port), protocol
|
yield ip, int(port), protocol
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ uvicorn[standard]==0.24.0
|
|||||||
websockets==12.0
|
websockets==12.0
|
||||||
aiosqlite==0.19.0
|
aiosqlite==0.19.0
|
||||||
aiohttp==3.9.1
|
aiohttp==3.9.1
|
||||||
|
aiohttp-socks==0.9.1
|
||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
lxml==5.1.0
|
lxml==5.1.0
|
||||||
|
|||||||
142
script/README.md
142
script/README.md
@@ -1,142 +0,0 @@
|
|||||||
# Proxy Pool Startup Scripts
|
|
||||||
|
|
||||||
## File List
|
|
||||||
|
|
||||||
- **start_backend.bat** - Start backend service
|
|
||||||
- **start_frontend.bat** - Start frontend service
|
|
||||||
- **stop_all.bat** - Stop all services
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Start Services Separately
|
|
||||||
- Backend: Double-click `start_backend.bat`
|
|
||||||
- Frontend: Double-click `start_frontend.bat`
|
|
||||||
|
|
||||||
### Stop Services
|
|
||||||
Double-click `stop_all.bat` to stop all services
|
|
||||||
|
|
||||||
## Script Features
|
|
||||||
|
|
||||||
### Smart Process Management
|
|
||||||
- Automatically detect and stop running processes
|
|
||||||
- Prevent duplicate startup of multiple instances
|
|
||||||
- Automatically clean up port conflicts
|
|
||||||
|
|
||||||
### Log Management
|
|
||||||
- All output written to log files
|
|
||||||
- Backend log: `backend.log`
|
|
||||||
- Frontend log: `frontend.log`
|
|
||||||
- Logs include timestamps for troubleshooting
|
|
||||||
|
|
||||||
### PID Management
|
|
||||||
- Automatically record process ID to PID files
|
|
||||||
- Facilitates subsequent service stopping
|
|
||||||
- Automatically clean up PID files after process stops
|
|
||||||
|
|
||||||
### Port Cleanup
|
|
||||||
- Automatically detect and clean up port conflicts
|
|
||||||
- Backend port: 3000
|
|
||||||
- Frontend port: 8080
|
|
||||||
|
|
||||||
## Access Addresses
|
|
||||||
|
|
||||||
After successful startup:
|
|
||||||
- Backend API: http://localhost:3000
|
|
||||||
- Frontend UI: http://localhost:8080
|
|
||||||
|
|
||||||
## Manual Operations
|
|
||||||
|
|
||||||
### View Logs
|
|
||||||
```bash
|
|
||||||
# View backend log
|
|
||||||
type backend.log
|
|
||||||
|
|
||||||
# View frontend log
|
|
||||||
type frontend.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Stop Process
|
|
||||||
```bash
|
|
||||||
# View PID file content
|
|
||||||
type backend.pid
|
|
||||||
type frontend.pid
|
|
||||||
|
|
||||||
# Stop process using PID
|
|
||||||
taskkill /F /PID <ProcessID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Port Usage
|
|
||||||
```bash
|
|
||||||
# Check backend port
|
|
||||||
netstat -ano | findstr :3000
|
|
||||||
|
|
||||||
# Check frontend port
|
|
||||||
netstat -ano | findstr :8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Log Examples
|
|
||||||
|
|
||||||
### backend.log
|
|
||||||
```
|
|
||||||
[13:30:15.00] ========================================
|
|
||||||
[13:30:15.00] Starting backend service...
|
|
||||||
INFO: Started server process [12345]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### frontend.log
|
|
||||||
```
|
|
||||||
[13:30:20.00] ========================================
|
|
||||||
[13:30:20.00] Starting frontend service...
|
|
||||||
VITE v5.0.0 ready in 1234 ms
|
|
||||||
|
|
||||||
➜ Local: http://localhost:8080/
|
|
||||||
➜ Network: use --host to expose
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
1. **First-time frontend startup**: If dependencies are not installed, the script will automatically run `npm install`
|
|
||||||
2. **Virtual environment**: Ensure backend uses Python virtual environment (venv)
|
|
||||||
3. **Firewall**: Ensure firewall allows ports 3000 and 8080
|
|
||||||
4. **Antivirus**: Some antivirus software may block scripts, need to add to whitelist
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend Won't Start
|
|
||||||
1. Check if Python virtual environment is correctly installed
|
|
||||||
2. View `backend.log` log file
|
|
||||||
3. Confirm port 3000 is not in use
|
|
||||||
4. Check if dependency packages are complete: `venv\Scripts\pip list`
|
|
||||||
|
|
||||||
### Frontend Won't Start
|
|
||||||
1. Check if Node.js is installed: `node --version`
|
|
||||||
2. View `frontend.log` log file
|
|
||||||
3. Confirm port 8080 is not in use
|
|
||||||
4. Manually install dependencies: Enter frontend directory and run `npm install`
|
|
||||||
|
|
||||||
### Process Won't Stop
|
|
||||||
1. Manually find process: `tasklist | findstr python`
|
|
||||||
2. Force stop: `taskkill /F /IM python.exe`
|
|
||||||
3. Check port: `netstat -ano | findstr :3000`
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Modify Ports
|
|
||||||
- Backend: Modify port number in `api_server.py`
|
|
||||||
- Frontend: Modify port number in `vite.config.js`
|
|
||||||
- After modification, need to sync update port checking logic in scripts
|
|
||||||
|
|
||||||
### Custom Log Location
|
|
||||||
- Modify `LOG_FILE` variable in scripts
|
|
||||||
- Ensure directory exists and has write permissions
|
|
||||||
|
|
||||||
## Technical Support
|
|
||||||
|
|
||||||
If you encounter issues, please check:
|
|
||||||
1. Log files (backend.log, frontend.log)
|
|
||||||
2. PID files (backend.pid, frontend.pid)
|
|
||||||
3. Port usage (netstat -ano)
|
|
||||||
4. Process list (tasklist)
|
|
||||||
@@ -1,9 +1,95 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
setlocal
|
echo === ProxyPool Startup ===
|
||||||
cd /d %~dp0
|
echo.
|
||||||
|
|
||||||
REM Launch via PowerShell to avoid encoding issues with Chinese characters
|
set "ROOT_PATH=%~dp0.."
|
||||||
powershell -ExecutionPolicy Bypass -File start.ps1
|
set "BACKEND_PORT=9949"
|
||||||
|
set "FRONTEND_PORT=9948"
|
||||||
|
|
||||||
timeout /t 3
|
REM 1. Clean processes on ports
|
||||||
|
echo [1/4] Cleaning old processes...
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%BACKEND_PORT%" ^| findstr "LISTENING"') do (
|
||||||
|
taskkill /F /PID %%a >nul 2>&1
|
||||||
|
echo Stopped port %BACKEND_PORT% (PID: %%a)
|
||||||
|
)
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%FRONTEND_PORT%" ^| findstr "LISTENING"') do (
|
||||||
|
taskkill /F /PID %%a >nul 2>&1
|
||||||
|
echo Stopped port %FRONTEND_PORT% (PID: %%a)
|
||||||
|
)
|
||||||
|
echo Cleanup complete!
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 2. Start Backend
|
||||||
|
echo [2/4] Starting backend (FastAPI)...
|
||||||
|
if exist "%ROOT_PATH%\venv\Scripts\python.exe" (
|
||||||
|
set "PYTHON_PATH=%ROOT_PATH%\venv\Scripts\python.exe"
|
||||||
|
echo Using venv
|
||||||
|
) else (
|
||||||
|
set "PYTHON_PATH=python"
|
||||||
|
echo Using system Python
|
||||||
|
)
|
||||||
|
|
||||||
|
cd /d "%ROOT_PATH%"
|
||||||
|
set "PYTHONIOENCODING=utf-8"
|
||||||
|
|
||||||
|
REM Clear old logs
|
||||||
|
if exist "%ROOT_PATH%\logs\backend_startup.log" del /f "%ROOT_PATH%\logs\backend_startup.log" >nul 2>&1
|
||||||
|
if exist "%ROOT_PATH%\logs\backend_error.log" del /f "%ROOT_PATH%\logs\backend_error.log" >nul 2>&1
|
||||||
|
|
||||||
|
REM Start backend
|
||||||
|
start /B "" "%PYTHON_PATH%" -u api_server.py >"%ROOT_PATH%\logs\backend_startup.log" 2>"%ROOT_PATH%\logs\backend_error.log"
|
||||||
|
echo Backend started
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 3. Wait for backend
|
||||||
|
echo [3/4] Waiting for backend...
|
||||||
|
set RETRY_COUNT=0
|
||||||
|
set BACKEND_READY=0
|
||||||
|
|
||||||
|
:WAIT_LOOP
|
||||||
|
if %RETRY_COUNT% geq 10 goto WAIT_DONE
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
set /a RETRY_COUNT+=1
|
||||||
|
|
||||||
|
ping -n 1 127.0.0.1 -w 500 >nul
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
|
||||||
|
REM Try to connect to backend
|
||||||
|
powershell -Command "try { $r = Invoke-RestMethod -Uri 'http://127.0.0.1:9949/' -TimeoutSec 2 -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
set BACKEND_READY=1
|
||||||
|
goto WAIT_DONE
|
||||||
|
)
|
||||||
|
echo Waiting... (%RETRY_COUNT%/10)
|
||||||
|
|
||||||
|
if exist "%ROOT_PATH%\logs\backend_startup.log" (
|
||||||
|
for /f "delims=" %%i in ('powershell -Command "Get-Content '%ROOT_PATH%\logs\backend_startup.log' -Tail 1" 2^>nul') do (
|
||||||
|
echo Log: %%i
|
||||||
|
)
|
||||||
|
)
|
||||||
|
goto WAIT_LOOP
|
||||||
|
|
||||||
|
:WAIT_DONE
|
||||||
|
if %BACKEND_READY% equ 0 (
|
||||||
|
echo.
|
||||||
|
echo Backend failed to start!
|
||||||
|
echo Check error log: %ROOT_PATH%\logs\backend_error.log
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo Backend is ready!
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 4. Start Frontend
|
||||||
|
echo [4/4] Starting frontend (Vite)...
|
||||||
|
start /B "" cmd /c "cd /d "%ROOT_PATH%\frontend" && npm run dev" >nul 2>&1
|
||||||
|
echo Frontend started
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo === All services started ===
|
||||||
|
echo Backend: http://127.0.0.1:9949
|
||||||
|
echo Frontend: http://localhost:9948
|
||||||
|
echo.
|
||||||
|
echo Please open frontend in browser
|
||||||
|
timeout /t 5 >nul
|
||||||
|
|||||||
109
script/start.ps1
109
script/start.ps1
@@ -1,109 +0,0 @@
|
|||||||
# ProxyPool Startup Script
|
|
||||||
$rootPath = Split-Path $PSScriptRoot -Parent
|
|
||||||
|
|
||||||
Write-Host "=== ProxyPool Startup ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 1. Clean processes on ports 8923 and 6173
|
|
||||||
Write-Host "[1/4] Cleaning old processes..." -ForegroundColor Cyan
|
|
||||||
$ports = @(8923, 6173)
|
|
||||||
foreach ($port in $ports) {
|
|
||||||
try {
|
|
||||||
$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
|
|
||||||
if ($conn) {
|
|
||||||
$processId = $conn.OwningProcess
|
|
||||||
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
|
||||||
Write-Host " Stopped port $port (PID: $processId)" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
Write-Host " Cleanup complete!" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 2. Start Backend (FastAPI)
|
|
||||||
Write-Host "[2/4] Starting backend (FastAPI)..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
$venvPython = "$rootPath\venv\Scripts\python.exe"
|
|
||||||
if (Test-Path $venvPython) {
|
|
||||||
$pythonPath = $venvPython
|
|
||||||
Write-Host " Using venv: $venvPython" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
$pythonPath = (Get-Command python).Source
|
|
||||||
Write-Host " Using system Python: $pythonPath" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
$env:PYTHONIOENCODING = "utf-8"
|
|
||||||
|
|
||||||
$backendLog = "$rootPath\logs\backend_startup.log"
|
|
||||||
$backendErr = "$rootPath\logs\backend_error.log"
|
|
||||||
|
|
||||||
# Clear old logs
|
|
||||||
if (Test-Path $backendLog) { Remove-Item $backendLog -Force }
|
|
||||||
if (Test-Path $backendErr) { Remove-Item $backendErr -Force }
|
|
||||||
|
|
||||||
# Start backend with -u flag for unbuffered output and redirect logs
|
|
||||||
$backendProcess = Start-Process -FilePath $pythonPath -ArgumentList "-u", "api_server.py" -WorkingDirectory "$rootPath" -RedirectStandardOutput $backendLog -RedirectStandardError $backendErr -WindowStyle Hidden -PassThru
|
|
||||||
|
|
||||||
Write-Host " Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 3. Wait for backend to be ready (max 10 seconds)
|
|
||||||
Write-Host "[3/4] Waiting for backend..." -ForegroundColor Cyan
|
|
||||||
$maxRetries = 5
|
|
||||||
$retryCount = 0
|
|
||||||
$backendReady = $false
|
|
||||||
|
|
||||||
while (-not $backendReady -and $retryCount -lt $maxRetries) {
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
$retryCount++
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Invoke-RestMethod -Uri "http://127.0.0.1:8923/" -Method Get -TimeoutSec 2 -ErrorAction Stop
|
|
||||||
if ($response) {
|
|
||||||
$backendReady = $true
|
|
||||||
Write-Host " Backend is ready!" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$errMessage = $_.Exception.Message
|
|
||||||
Write-Host " Waiting... ($retryCount/$maxRetries)" -ForegroundColor Yellow
|
|
||||||
|
|
||||||
if (Test-Path $backendLog) {
|
|
||||||
$lastLog = Get-Content $backendLog -Tail 1 -ErrorAction SilentlyContinue
|
|
||||||
if ($lastLog) { Write-Host " Log: $lastLog" -ForegroundColor DarkGray }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($backendProcess.HasExited) {
|
|
||||||
Write-Host " Backend process exited!" -ForegroundColor Red
|
|
||||||
Write-Host " Exit code: $($backendProcess.ExitCode)" -ForegroundColor Red
|
|
||||||
if (Test-Path $backendErr) {
|
|
||||||
Write-Host "" -ForegroundColor Red
|
|
||||||
Write-Host "Error log:" -ForegroundColor Red
|
|
||||||
Get-Content $backendErr -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
|
|
||||||
}
|
|
||||||
$backendReady = $false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $backendReady) {
|
|
||||||
Write-Host "" -ForegroundColor Red
|
|
||||||
Write-Host "Backend failed to start!" -ForegroundColor Red
|
|
||||||
Write-Host "Check error log: $backendErr" -ForegroundColor Red
|
|
||||||
pause
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# 4. Start Frontend (Vite)
|
|
||||||
Write-Host "[4/4] Starting frontend (Vite)..." -ForegroundColor Cyan
|
|
||||||
Start-Process -FilePath "cmd" -ArgumentList "/c npm run dev" -WorkingDirectory "$rootPath\frontend" -WindowStyle Hidden
|
|
||||||
Write-Host " Frontend started" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host "=== All services started ===" -ForegroundColor Cyan
|
|
||||||
Write-Host "Backend: http://127.0.0.1:8923" -ForegroundColor Green
|
|
||||||
Write-Host "Frontend: http://localhost:6173" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Please open frontend in browser" -ForegroundColor Magenta
|
|
||||||
@@ -1,8 +1,41 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
setlocal
|
echo === Stopping ProxyPool Services ===
|
||||||
cd /d %~dp0
|
echo.
|
||||||
|
|
||||||
powershell -ExecutionPolicy Bypass -File stop.ps1
|
set "BACKEND_PORT=9949"
|
||||||
|
set "FRONTEND_PORT=9948"
|
||||||
|
set "STOPPED_COUNT=0"
|
||||||
|
|
||||||
|
echo [1/2] Stopping processes on ports %BACKEND_PORT% and %FRONTEND_PORT%...
|
||||||
|
|
||||||
|
REM Stop backend port
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%BACKEND_PORT%" ^| findstr "LISTENING"') do (
|
||||||
|
for /f "tokens=1" %%b in ('tasklist /FI "PID eq %%a" ^| findstr "%%a"') do (
|
||||||
|
taskkill /F /PID %%a >nul 2>&1
|
||||||
|
echo Stopped port %BACKEND_PORT% (PID: %%a, Process: %%b)
|
||||||
|
set /a STOPPED_COUNT+=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Stop frontend port
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%FRONTEND_PORT%" ^| findstr "LISTENING"') do (
|
||||||
|
for /f "tokens=1" %%b in ('tasklist /FI "PID eq %%a" ^| findstr "%%a"') do (
|
||||||
|
taskkill /F /PID %%a >nul 2>&1
|
||||||
|
echo Stopped port %FRONTEND_PORT% (PID: %%a, Process: %%b)
|
||||||
|
set /a STOPPED_COUNT+=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Stopped %STOPPED_COUNT% process(es)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [2/2] Waiting for processes to fully stop...
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo === Done ===
|
||||||
|
echo All services have been stopped.
|
||||||
|
echo.
|
||||||
|
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# ProxyPool Stop Script
|
|
||||||
$rootPath = Split-Path $PSScriptRoot -Parent
|
|
||||||
|
|
||||||
Write-Host "=== Stopping ProxyPool Services ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host "[1/2] Stopping processes on ports 8923 and 6173..." -ForegroundColor Cyan
|
|
||||||
$ports = @(8923, 6173)
|
|
||||||
$stoppedCount = 0
|
|
||||||
|
|
||||||
foreach ($port in $ports) {
|
|
||||||
try {
|
|
||||||
$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
|
|
||||||
if ($conn) {
|
|
||||||
$processId = $conn.OwningProcess
|
|
||||||
|
|
||||||
try {
|
|
||||||
$process = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
|
||||||
if ($process) {
|
|
||||||
$processName = $process.ProcessName
|
|
||||||
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
Write-Host " Stopped port $port (PID: $processId, Process: $processName)" -ForegroundColor Gray
|
|
||||||
$stoppedCount++
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Host " Warning: Could not stop process on port $port (PID: $processId)" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Host " Port ${port}: No process found" -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Host " Error checking port ${port}: $($_.Exception.Message)" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host " Stopped $stoppedCount process(es)" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host "[2/2] Waiting for processes to fully stop..." -ForegroundColor Cyan
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "=== Done ===" -ForegroundColor Cyan
|
|
||||||
Write-Host "All services have been stopped." -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
232
tasks_manager.py
232
tasks_manager.py
@@ -1,232 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from datetime import datetime
|
|
||||||
from core.plugin_manager import PluginManager
|
|
||||||
from core.sqlite import SQLiteManager
|
|
||||||
from core.validator import ProxyValidator
|
|
||||||
from core.log import logger
|
|
||||||
from typing import Optional, Callable
|
|
||||||
|
|
||||||
class TasksManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.is_running = False
|
|
||||||
self.stop_requested = False
|
|
||||||
self.current_task = None
|
|
||||||
self.validator_tasks = []
|
|
||||||
self.progress_callback = None
|
|
||||||
self.status_callback = None
|
|
||||||
self.proxy_queue = asyncio.Queue(maxsize=500)
|
|
||||||
self.stats = {
|
|
||||||
'total_found': 0,
|
|
||||||
'total_verified': 0,
|
|
||||||
'start_time': None,
|
|
||||||
'current_url': None,
|
|
||||||
'plugins': []
|
|
||||||
}
|
|
||||||
self.estimated_total = 1000
|
|
||||||
|
|
||||||
def set_callbacks(self, progress_callback: Optional[Callable] = None, status_callback: Optional[Callable] = None):
|
|
||||||
self.progress_callback = progress_callback
|
|
||||||
self.status_callback = status_callback
|
|
||||||
|
|
||||||
async def _notify_progress(self, data: dict):
|
|
||||||
if self.progress_callback:
|
|
||||||
data['timestamp'] = datetime.now().isoformat()
|
|
||||||
|
|
||||||
if 'found' in data and 'verified' in data:
|
|
||||||
data['success_rate'] = round((data['verified'] / data['found'] * 100), 2) if data['found'] > 0 else 0
|
|
||||||
|
|
||||||
if 'found' in data:
|
|
||||||
data['current'] = data['found'] + self.stats['total_verified']
|
|
||||||
data['total'] = self.estimated_total
|
|
||||||
|
|
||||||
await self.progress_callback(data)
|
|
||||||
|
|
||||||
async def _notify_status(self, status: str, message: str):
|
|
||||||
if self.status_callback:
|
|
||||||
await self.status_callback({
|
|
||||||
'status': status,
|
|
||||||
'message': message,
|
|
||||||
'timestamp': datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
async def run_crawler(self):
|
|
||||||
await self._notify_status('crawling_start', '开始爬取代理啦~')
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
self.stats['plugins'] = [plugin.name for plugin in manager.plugins]
|
|
||||||
|
|
||||||
async for ip, port, protocol in manager.run_all():
|
|
||||||
if self.stop_requested:
|
|
||||||
logger.info("爬虫收到停止信号")
|
|
||||||
break
|
|
||||||
await self.proxy_queue.put((ip, port, protocol))
|
|
||||||
count += 1
|
|
||||||
self.stats['total_found'] = count
|
|
||||||
|
|
||||||
if count % 5 == 0:
|
|
||||||
await self._notify_progress({
|
|
||||||
'type': 'crawling',
|
|
||||||
'found': count,
|
|
||||||
'verified': self.stats['total_verified'],
|
|
||||||
'current_proxy': f"{ip}:{port}",
|
|
||||||
'message': f'正在爬取:已发现 {count} 个代理'
|
|
||||||
})
|
|
||||||
|
|
||||||
if self.stop_requested:
|
|
||||||
await self._notify_status('stopped', '爬虫已停止啦~')
|
|
||||||
else:
|
|
||||||
await self._notify_status('crawling_done', f'爬虫抓取完成啦,共发现 {count} 个潜在代理~')
|
|
||||||
logger.info(f"爬虫抓取阶段完成,共发现 {count} 个潜在代理。")
|
|
||||||
|
|
||||||
async def run_validator(self, db: SQLiteManager, validator: ProxyValidator):
|
|
||||||
await self._notify_status('validating_start', '开始验证代理啦~')
|
|
||||||
verified_count = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
proxy = await self.proxy_queue.get()
|
|
||||||
if proxy is None or self.stop_requested:
|
|
||||||
self.proxy_queue.task_done()
|
|
||||||
break
|
|
||||||
|
|
||||||
ip, port, protocol = proxy
|
|
||||||
try:
|
|
||||||
is_valid, latency = await validator.validate(ip, port, protocol)
|
|
||||||
if is_valid:
|
|
||||||
logger.info(f"验证通过: {ip}:{port} ({protocol}) - 延迟: {latency}ms")
|
|
||||||
await db.insert_proxy(ip, port, protocol)
|
|
||||||
verified_count += 1
|
|
||||||
self.stats['total_verified'] = verified_count
|
|
||||||
|
|
||||||
await self._notify_progress({
|
|
||||||
'type': 'validating',
|
|
||||||
'found': self.stats['total_found'],
|
|
||||||
'verified': verified_count,
|
|
||||||
'current_proxy': f"{ip}:{port}",
|
|
||||||
'message': f'正在验证:已验证 {verified_count} 个代理'
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
logger.info(f"验证失败: {ip}:{port} ({protocol})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"验证器异常: {e}")
|
|
||||||
finally:
|
|
||||||
self.proxy_queue.task_done()
|
|
||||||
|
|
||||||
if self.stop_requested:
|
|
||||||
await self._notify_status('stopped', '验证器已停止啦~')
|
|
||||||
elif verified_count > 0:
|
|
||||||
await self._notify_status('validating_done', f'验证完成啦,入库 {verified_count} 个代理~')
|
|
||||||
logger.info(f"验证协程完成,入库 {verified_count} 个代理。")
|
|
||||||
|
|
||||||
async def start_task(self, db: SQLiteManager, num_validators: int = 50):
|
|
||||||
if self.is_running:
|
|
||||||
await self._notify_status('error', '任务正在运行中呢~')
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.is_running = True
|
|
||||||
self.stop_requested = False
|
|
||||||
self.stats = {
|
|
||||||
'total_found': 0,
|
|
||||||
'total_verified': 0,
|
|
||||||
'start_time': datetime.now().isoformat(),
|
|
||||||
'current_url': None,
|
|
||||||
'plugins': []
|
|
||||||
}
|
|
||||||
|
|
||||||
await self._notify_status('connecting', '正在连接插件源...')
|
|
||||||
await self._notify_status('starting', '正在启动爬虫...')
|
|
||||||
await self._notify_status('running', '任务开始啦~')
|
|
||||||
|
|
||||||
async with ProxyValidator(max_concurrency=200) as validator:
|
|
||||||
crawler_task = asyncio.create_task(self.run_crawler())
|
|
||||||
self.validator_tasks = [asyncio.create_task(self.run_validator(db, validator)) for _ in range(num_validators)]
|
|
||||||
|
|
||||||
await crawler_task
|
|
||||||
|
|
||||||
for _ in range(num_validators):
|
|
||||||
await self.proxy_queue.put(None)
|
|
||||||
|
|
||||||
await self.proxy_queue.join()
|
|
||||||
await asyncio.gather(*self.validator_tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
total = await db.count_proxies()
|
|
||||||
self.is_running = False
|
|
||||||
self.stop_requested = False
|
|
||||||
|
|
||||||
if not self.stop_requested:
|
|
||||||
await self._notify_status('completed', f'任务完成啦,当前池内总数: {total}~')
|
|
||||||
await self._notify_progress({
|
|
||||||
'type': 'completed',
|
|
||||||
'found': self.stats['total_found'],
|
|
||||||
'verified': self.stats['total_verified'],
|
|
||||||
'total': total
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"=== 运行结束,当前池内总数: {total} ===")
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def stop_task(self):
|
|
||||||
if not self.is_running:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.stop_requested = True
|
|
||||||
|
|
||||||
# 取消所有验证器任务
|
|
||||||
for task in self.validator_tasks:
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
# 清空队列并添加停止信号
|
|
||||||
while not self.proxy_queue.empty():
|
|
||||||
try:
|
|
||||||
self.proxy_queue.get_nowait()
|
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 添加停止信号到队列
|
|
||||||
for _ in range(len(self.validator_tasks)):
|
|
||||||
await self.proxy_queue.put(None)
|
|
||||||
|
|
||||||
await self._notify_status('stopped', '任务已停止~')
|
|
||||||
logger.info("任务被手动停止")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
|
||||||
return self.stats.copy()
|
|
||||||
|
|
||||||
def is_task_running(self) -> bool:
|
|
||||||
return self.is_running
|
|
||||||
|
|
||||||
class ScheduledTasks:
|
|
||||||
def __init__(self, tasks_manager: TasksManager):
|
|
||||||
self.tasks_manager = tasks_manager
|
|
||||||
self.scheduler_task = None
|
|
||||||
self.is_scheduled = False
|
|
||||||
self.interval_minutes = 60
|
|
||||||
|
|
||||||
async def scheduler(self):
|
|
||||||
from core.sqlite import SQLiteManager
|
|
||||||
|
|
||||||
while self.is_scheduled:
|
|
||||||
try:
|
|
||||||
db = SQLiteManager()
|
|
||||||
await db.init_db()
|
|
||||||
|
|
||||||
await self.tasks_manager.start_task(db, num_validators=50)
|
|
||||||
|
|
||||||
await asyncio.sleep(self.interval_minutes * 60)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"定时任务异常: {e}")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
|
|
||||||
def start_scheduled(self, interval_minutes: int = 60):
|
|
||||||
self.interval_minutes = interval_minutes
|
|
||||||
self.is_scheduled = True
|
|
||||||
self.scheduler_task = asyncio.create_task(self.scheduler())
|
|
||||||
logger.info(f"定时任务已启动,间隔: {interval_minutes} 分钟")
|
|
||||||
|
|
||||||
def stop_scheduled(self):
|
|
||||||
self.is_scheduled = False
|
|
||||||
if self.scheduler_task:
|
|
||||||
self.scheduler_task.cancel()
|
|
||||||
logger.info("定时任务已停止")
|
|
||||||
@@ -1,724 +0,0 @@
|
|||||||
{
|
|
||||||
"summary": {
|
|
||||||
"total_tests": 29,
|
|
||||||
"passed_tests": 29,
|
|
||||||
"failed_tests": 0,
|
|
||||||
"pass_rate": 100.0,
|
|
||||||
"timestamp": "2026-01-27T23:11:59.292107"
|
|
||||||
},
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"test_name": "GET / - 根路径访问",
|
|
||||||
"passed": true,
|
|
||||||
"message": "根路径返回正常",
|
|
||||||
"timestamp": "2026-01-27T23:11:21.092484",
|
|
||||||
"response_data": {
|
|
||||||
"message": "欢迎使用代理池API~",
|
|
||||||
"status": "running",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /health - 健康检查",
|
|
||||||
"passed": true,
|
|
||||||
"message": "服务健康状态正常",
|
|
||||||
"timestamp": "2026-01-27T23:11:23.104732",
|
|
||||||
"response_data": {
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": "2026-01-27T23:11:23.104732",
|
|
||||||
"database": "connected",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/stats - 统计信息",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取统计信息,总数: 220",
|
|
||||||
"timestamp": "2026-01-27T23:11:25.116587",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取统计信息成功啦~",
|
|
||||||
"data": {
|
|
||||||
"total": 220,
|
|
||||||
"available": 220,
|
|
||||||
"avg_score": 10.0,
|
|
||||||
"http_count": 147,
|
|
||||||
"https_count": 0,
|
|
||||||
"socks4_count": 73,
|
|
||||||
"socks5_count": 0,
|
|
||||||
"today_new": 220
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/stats - 字段完整性",
|
|
||||||
"passed": true,
|
|
||||||
"message": "所有必需字段都存在",
|
|
||||||
"timestamp": "2026-01-27T23:11:25.116587",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 基本分页查询",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取代理列表,共 220 条",
|
|
||||||
"timestamp": "2026-01-27T23:11:27.126629",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取代理列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"ip": "120.26.68.107",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:23.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "169.61.46.13",
|
|
||||||
"port": 7563,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:23.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "35.209.198.222",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:21.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "34.81.160.132",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:21.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "176.126.164.213",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:19.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "8.220.136.174",
|
|
||||||
"port": 5060,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:15.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "47.86.53.59",
|
|
||||||
"port": 8080,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:14.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "40.177.106.156",
|
|
||||||
"port": 8080,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:10.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "47.56.110.204",
|
|
||||||
"port": 8989,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:10.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "193.53.127.169",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:09.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "163.172.167.48",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:08.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "36.67.136.27",
|
|
||||||
"port": 5678,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:08.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "162.223.90.144",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:08.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "104.197.218.238",
|
|
||||||
"port": 8080,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:59.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "211.230.49.122",
|
|
||||||
"port": 3128,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:54.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "159.195.84.83",
|
|
||||||
"port": 443,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:53.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "172.237.73.24",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:52.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "81.169.213.169",
|
|
||||||
"port": 8888,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:47.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "8.220.141.8",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:46.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "31.28.4.192",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:45.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 220,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 基本分页查询 - 字段完整性",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理数据字段完整",
|
|
||||||
"timestamp": "2026-01-27T23:11:27.126629",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带协议筛选",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取代理列表,共 147 条",
|
|
||||||
"timestamp": "2026-01-27T23:11:29.137101",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取代理列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"ip": "47.89.159.212",
|
|
||||||
"port": 1080,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:27.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "200.59.186.177",
|
|
||||||
"port": 999,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:26.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "34.76.142.148",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:22.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "101.47.16.15",
|
|
||||||
"port": 7890,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:22.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "212.114.194.72",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:21.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "8.213.156.191",
|
|
||||||
"port": 221,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:21.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "191.101.1.116",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:20.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "51.141.175.118",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:19.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "213.73.25.230",
|
|
||||||
"port": 8080,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:19.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "50.203.147.152",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:17.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 147,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带协议筛选 - 字段完整性",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理数据字段完整",
|
|
||||||
"timestamp": "2026-01-27T23:11:29.137101",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带分数筛选",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取代理列表,共 0 条",
|
|
||||||
"timestamp": "2026-01-27T23:11:31.148007",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取代理列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"list": [],
|
|
||||||
"total": 0,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带分数筛选 - 空列表",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理列表为空(可能数据库无数据)",
|
|
||||||
"timestamp": "2026-01-27T23:11:31.148007",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取代理列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"list": [],
|
|
||||||
"total": 0,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带排序",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取代理列表,共 221 条",
|
|
||||||
"timestamp": "2026-01-27T23:11:33.159151",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取代理列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"ip": "212.114.194.75",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:28.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "35.209.198.222",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:21.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "40.177.106.156",
|
|
||||||
"port": 8080,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:10.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "163.172.167.48",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:11:08.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "159.195.84.83",
|
|
||||||
"port": 443,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:53.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "31.28.4.192",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:45.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "108.170.12.10",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:44.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "35.180.127.14",
|
|
||||||
"port": 1001,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:42.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "139.162.200.213",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:42.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ip": "154.90.48.76",
|
|
||||||
"port": 80,
|
|
||||||
"protocol": "socks4",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:10:38.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 221,
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 带排序 - 字段完整性",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理数据字段完整",
|
|
||||||
"timestamp": "2026-01-27T23:11:33.159151",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 参数验证测试 - 无效协议",
|
|
||||||
"passed": true,
|
|
||||||
"message": "参数验证失败,符合预期",
|
|
||||||
"timestamp": "2026-01-27T23:11:35.168328",
|
|
||||||
"response_data": {
|
|
||||||
"detail": [
|
|
||||||
{
|
|
||||||
"type": "value_error",
|
|
||||||
"loc": [
|
|
||||||
"body",
|
|
||||||
"protocol"
|
|
||||||
],
|
|
||||||
"msg": "Value error, 协议类型必须是 http, https, socks4 或 socks5",
|
|
||||||
"input": "invalid",
|
|
||||||
"ctx": {
|
|
||||||
"error": {}
|
|
||||||
},
|
|
||||||
"url": "https://errors.pydantic.dev/2.12/v/value_error"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 参数验证测试 - page为0",
|
|
||||||
"passed": true,
|
|
||||||
"message": "参数验证失败,符合预期",
|
|
||||||
"timestamp": "2026-01-27T23:11:37.176455",
|
|
||||||
"response_data": {
|
|
||||||
"detail": [
|
|
||||||
{
|
|
||||||
"type": "greater_than_equal",
|
|
||||||
"loc": [
|
|
||||||
"body",
|
|
||||||
"page"
|
|
||||||
],
|
|
||||||
"msg": "Input should be greater than or equal to 1",
|
|
||||||
"input": 0,
|
|
||||||
"ctx": {
|
|
||||||
"ge": 1
|
|
||||||
},
|
|
||||||
"url": "https://errors.pydantic.dev/2.12/v/greater_than_equal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "POST /api/proxies - 参数验证测试 - page_size超过100",
|
|
||||||
"passed": true,
|
|
||||||
"message": "参数验证失败,符合预期",
|
|
||||||
"timestamp": "2026-01-27T23:11:39.186465",
|
|
||||||
"response_data": {
|
|
||||||
"detail": [
|
|
||||||
{
|
|
||||||
"type": "less_than_equal",
|
|
||||||
"loc": [
|
|
||||||
"body",
|
|
||||||
"page_size"
|
|
||||||
],
|
|
||||||
"msg": "Input should be less than or equal to 100",
|
|
||||||
"input": 101,
|
|
||||||
"ctx": {
|
|
||||||
"le": 100
|
|
||||||
},
|
|
||||||
"url": "https://errors.pydantic.dev/2.12/v/less_than_equal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/random - 获取随机代理",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取随机代理: 176.126.103.194:44214",
|
|
||||||
"timestamp": "2026-01-27T23:11:41.196335",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取随机代理成功啦~",
|
|
||||||
"data": {
|
|
||||||
"ip": "176.126.103.194",
|
|
||||||
"port": 44214,
|
|
||||||
"protocol": "http",
|
|
||||||
"score": 10,
|
|
||||||
"last_check": "2026-01-27T15:08:12.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/有效代理",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理不存在(符合预期)",
|
|
||||||
"timestamp": "2026-01-27T23:11:43.202256",
|
|
||||||
"response_data": {
|
|
||||||
"code": 404,
|
|
||||||
"message": "代理不存在呢~",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/不存在的代理",
|
|
||||||
"passed": true,
|
|
||||||
"message": "代理不存在(符合预期)",
|
|
||||||
"timestamp": "2026-01-27T23:11:45.210946",
|
|
||||||
"response_data": {
|
|
||||||
"code": 404,
|
|
||||||
"message": "代理不存在呢~",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/csv - 导出CSV格式",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功导出CSV格式,内容长度: 552",
|
|
||||||
"timestamp": "2026-01-27T23:11:47.221104",
|
|
||||||
"response_data": {
|
|
||||||
"content_length": 552
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/csv - CSV格式验证",
|
|
||||||
"passed": true,
|
|
||||||
"message": "CSV格式正确,包含表头",
|
|
||||||
"timestamp": "2026-01-27T23:11:47.221104",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/txt - 导出TXT格式",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功导出TXT格式,内容长度: 184",
|
|
||||||
"timestamp": "2026-01-27T23:11:49.226991",
|
|
||||||
"response_data": {
|
|
||||||
"content_length": 184
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/txt - TXT格式验证",
|
|
||||||
"passed": true,
|
|
||||||
"message": "TXT格式正确",
|
|
||||||
"timestamp": "2026-01-27T23:11:49.228522",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/json - 导出JSON格式",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功导出JSON格式,内容长度: 1260",
|
|
||||||
"timestamp": "2026-01-27T23:11:51.242429",
|
|
||||||
"response_data": {
|
|
||||||
"content_length": 1260
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/json - JSON格式验证",
|
|
||||||
"passed": true,
|
|
||||||
"message": "JSON格式正确",
|
|
||||||
"timestamp": "2026-01-27T23:11:51.244593",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/proxies/export/invalid - 无效格式测试",
|
|
||||||
"passed": true,
|
|
||||||
"message": "正确返回400错误",
|
|
||||||
"timestamp": "2026-01-27T23:11:53.258979",
|
|
||||||
"response_data": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/crawler/status - 获取爬虫状态",
|
|
||||||
"passed": true,
|
|
||||||
"message": "爬虫状态: 运行中",
|
|
||||||
"timestamp": "2026-01-27T23:11:55.270148",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取爬虫状态成功啦~",
|
|
||||||
"data": {
|
|
||||||
"running": true,
|
|
||||||
"stats": {
|
|
||||||
"total_found": 5524,
|
|
||||||
"total_verified": 4,
|
|
||||||
"start_time": "2026-01-27T23:06:12.013714",
|
|
||||||
"current_url": null,
|
|
||||||
"plugins": [
|
|
||||||
"IP3366",
|
|
||||||
"89免费代理",
|
|
||||||
"快代理",
|
|
||||||
"ProxyListDownload",
|
|
||||||
"SpeedX代理源",
|
|
||||||
"云代理"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/scheduler - 获取定时任务状态",
|
|
||||||
"passed": true,
|
|
||||||
"message": "定时任务状态: 未启用",
|
|
||||||
"timestamp": "2026-01-27T23:11:57.282485",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取定时任务状态成功啦~",
|
|
||||||
"data": {
|
|
||||||
"enabled": false,
|
|
||||||
"interval_minutes": 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/plugins - 获取插件列表",
|
|
||||||
"passed": true,
|
|
||||||
"message": "成功获取插件列表,共 6 个插件",
|
|
||||||
"timestamp": "2026-01-27T23:11:59.290536",
|
|
||||||
"response_data": {
|
|
||||||
"code": 200,
|
|
||||||
"message": "获取插件列表成功啦~",
|
|
||||||
"data": {
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"id": "IP3366",
|
|
||||||
"name": "IP3366",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从IP3366网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "89免费代理",
|
|
||||||
"name": "89免费代理",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从89免费代理网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "快代理",
|
|
||||||
"name": "快代理",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从快代理网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ProxyListDownload",
|
|
||||||
"name": "ProxyListDownload",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从ProxyListDownload网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "SpeedX代理源",
|
|
||||||
"name": "SpeedX代理源",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从SpeedX代理源网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "云代理",
|
|
||||||
"name": "云代理",
|
|
||||||
"enabled": true,
|
|
||||||
"description": "从云代理网站爬取代理",
|
|
||||||
"last_run": null,
|
|
||||||
"success_count": 0,
|
|
||||||
"failure_count": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"test_name": "GET /api/plugins - 插件字段完整性",
|
|
||||||
"passed": true,
|
|
||||||
"message": "插件数据字段完整",
|
|
||||||
"timestamp": "2026-01-27T23:11:59.290536",
|
|
||||||
"response_data": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user