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

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

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

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

View File

@@ -6,7 +6,7 @@ DB_PATH=db/proxies.sqlite
# ==================== API服务配置 ====================
HOST=0.0.0.0
PORT=3000
PORT=9949
# ==================== 验证器配置 ====================
VALIDATOR_TIMEOUT=5
@@ -17,10 +17,6 @@ VALIDATOR_CONNECT_TIMEOUT=3
CRAWLER_NUM_VALIDATORS=50
CRAWLER_MAX_QUEUE_SIZE=500
# ==================== 定时任务配置 ====================
SCHEDULER_INTERVAL_MINUTES=60
SCHEDULER_ENABLED=false
# ==================== 日志配置 ====================
LOG_LEVEL=INFO
LOG_DIR=logs
@@ -34,29 +30,9 @@ SCORE_INVALID=-5
SCORE_MIN=0
SCORE_MAX=100
# ==================== WebSocket配置 ====================
WS_PING_INTERVAL=20
WS_PING_TIMEOUT=20
# ==================== 插件配置 ====================
PLUGINS_DIR=plugins
# ==================== CORS配置 ====================
# 允许的来源域名,用逗号分隔
# 开发环境示例: http://localhost:8080,http://localhost:5173
# 生产环境示例: https://yourdomain.com,https://api.yourdomain.com
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
View File

@@ -89,3 +89,8 @@ proxies.sqlite*
# Test/Maintenance Scripts
clear_*.py
test_*.py
test_results.json
test_screenshot_*.png
# Legacy/Backup
backend/

101
README.md
View File

@@ -5,22 +5,21 @@
## 🌟 特性
- 🔮 **科技风设计** - 现代化的深色科技主题
- 📊 **实时监控** - WebSocket 实时推送任务进度
- 📊 **实时监控** - 自动统计代理池状态
- 🎯 **智能管理** - 代理查询、筛选、排序、批量操作
- 📥 **多格式导出** - 支持 CSV、TXT、JSON 格式
- **定时任务** - 自动定期更新代理池
- **自动验证** - 自动验证代理可用性并评分
- 🚀 **高性能** - 异步爬取和验证,支持高并发
## 📦 技术栈
### 后端
- **框架**: FastAPI (端口 8923)
- **框架**: FastAPI (端口 9949)
- **数据库**: SQLite + aiosqlite
- **异步**: asyncio
- **实时通信**: WebSocket
### 前端
- **框架**: Vue 3 + Vite (端口 6173)
- **框架**: Vue 3 + Vite (端口 9948)
- **UI库**: Element Plus
- **状态管理**: Pinia
- **图表**: ECharts
@@ -72,32 +71,27 @@ stop.bat
### 4. 访问 WebUI
打开浏览器访问:**http://localhost:6173**
打开浏览器访问:**http://localhost:9948**
## 📁 项目结构
```
ProxyPool/
├── api_server.py # FastAPI 后端服务器
├── tasks_manager.py # 任务管理器
├── main.py # 爬虫主程序
├── config.py # 配置文件
├── requirements.txt # Python 依赖
├── .env.example # 环境变量示例
├── script/ # 启动脚本
│ ├── start.bat # Windows 启动脚本
── start.ps1 # PowerShell 启动脚本
│ ├── stop.bat # Windows 停止脚本
│ └── README.md # 脚本说明文档
── stop.bat # Windows 停止脚本
├── core/ # 核心模块
│ ├── crawler.py # 爬虫基类
│ ├── validator.py # 代理验证器
│ ├── sqlite.py # 数据库管理
│ ├── plugin_manager.py # 插件管理器
── log.py # 日志配置
│ └── auth.py # 认证模块
── log.py # 日志配置
├── plugins/ # 代理源插件
│ ├── fate0.py # Fate0 代理源
@@ -115,12 +109,9 @@ ProxyPool/
│ │ ├── views/ # 页面组件
│ │ ├── router/ # 路由配置
│ │ ├── components/ # 通用组件
│ │ ├── App.vue
│ │ ├── main.js
│ │ └── style.css # 全局样式
│ ├── index.html
── package.json
│ └── vite.config.js
── package.json
└── db/ # 数据存储目录
└── proxies.sqlite # SQLite 数据库
@@ -151,46 +142,50 @@ POST /api/proxies
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
GET /api/scheduler
POST /api/proxies/batch-delete
```
### 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. **检查数据库**
@@ -200,10 +195,10 @@ ws://localhost:8923/ws
2. **手动测试 API**
```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" \
-d '{"page": 1, "page_size": 20}'
```
@@ -215,19 +210,19 @@ ws://localhost:8923/ws
## 📝 配置说明
### 爬虫配置
- **最大并发数**: 10-500默认 200
### 代理验证配置
- **验证超时**: 3-30秒默认 5秒
- **验证线程数**: 10-200默认 50
- **验证并发数**: 10-200默认 50
### 定时任务
- **执行间隔**: 10-1440分钟默认 60分钟
- **自动清理**: 可选,清理无效代理
### 评分机制
- **验证成功**: +10 分
- **验证失败**: -5 分
- **分数为 0**: 自动删除
## 🔧 常见问题
### Q: 启动后端口被占用?
A: 修改 `api_server.py` 最后一行的端口号(默认8923)或 `frontend/vite.config.js` 中的端口号(默认6173
A: 修改 `config.py` 的端口号(默认9949)或 `frontend/vite.config.js` 中的端口号(默认9948
### Q: 爬虫无法抓取代理?
A: 检查网络连接,确保能访问目标网站,或尝试更换代理源插件
@@ -236,7 +231,7 @@ A: 检查网络连接,确保能访问目标网站,或尝试更换代理源
A: 增加验证超时时间,或减少并发验证数量
### Q: 数据库文件在哪里?
A: 默认在 `db/proxies.sqlite`,可在 `core/sqlite.py` 中修改 `db_path`
A: 默认在 `db/proxies.sqlite`,可在 `config.py` 中修改 `DB_PATH`
### Q: 如何清空数据库?
A: 运行命令 `python -c "from core.sqlite import SQLiteManager; import asyncio; asyncio.run(SQLiteManager().clear_all())"`

89
README_SOCKS.md Normal file
View 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 支持认证,当前版本使用无认证模式

View File

@@ -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.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field, field_validator, ValidationError
from typing import Optional, List
import asyncio
import io
import csv
import json
from datetime import datetime
import re
import os
from contextlib import asynccontextmanager
from core.sqlite import SQLiteManager
from core.validator import ProxyValidator
from core.plugin_manager import PluginManager
from tasks_manager import TasksManager, ScheduledTasks
from core.scheduler import ValidationScheduler
from core.log import logger
from config import Config
from core.auth import verify_api_key, require_admin, PermissionLevel
from config import config
# 全局调度器实例
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
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
db = SQLiteManager()
await db.init_db()
logger.info("API服务器启动啦~")
yield
logger.info("API服务器关闭啦~")
app = FastAPI(title="代理池API", version="1.1.0", lifespan=lifespan)
# 加载设置并应用到调度器
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
# 关闭调度器
await scheduler.stop()
logger.info("API服务器关闭")
app = FastAPI(title="代理池API", version="1.3.0", lifespan=lifespan)
def format_datetime(datetime_str: str) -> str:
"""将数据库时间格式统一转换为ISO 8601格式"""
@@ -44,14 +102,16 @@ def format_datetime(datetime_str: str) -> str:
return datetime_str
@app.exception_handler(ValidationError)
async def validation_exception_handler(request: Request, exc: ValidationError):
logger.error(f"参数验证失败: {exc}")
return JSONResponse(
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)
async def http_exception_handler(request: Request, exc: HTTPException):
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}
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"未处理的异常: {exc}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"code": 500, "message": "服务器内部错误呢~", "data": None}
content={"code": 500, "message": "服务器内部错误", "data": None}
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -76,38 +138,8 @@ app.add_middleware(
allow_headers=["*"],
)
tasks_manager = TasksManager()
scheduled_tasks = ScheduledTasks(tasks_manager)
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):
page: int = Field(default=1, ge=1, description="页码必须大于等于1")
@@ -139,6 +171,7 @@ class ProxyRequest(BaseModel):
raise ValueError('排序方式必须是 ASC 或 DESC')
return v.upper()
class ProxyDeleteItem(BaseModel):
ip: str
port: int
@@ -150,6 +183,7 @@ class ProxyDeleteItem(BaseModel):
raise ValueError('端口号必须在1-65535范围内')
return v
class DeleteProxiesRequest(BaseModel):
proxies: List[ProxyDeleteItem]
@@ -160,16 +194,11 @@ class DeleteProxiesRequest(BaseModel):
raise ValueError('单次最多删除1000个代理')
return v
class CrawlerRequest(BaseModel):
num_validators: int = 50
class ScheduleRequest(BaseModel):
enabled: bool
interval_minutes: int = 60
@app.get("/")
async def root():
return {"message": "欢迎使用代理池API~", "status": "running", "data": None}
return {"message": "欢迎使用代理池API", "status": "running", "data": None}
@app.get("/health")
async def health_check():
@@ -180,7 +209,8 @@ async def health_check():
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"database": "connected",
"version": "1.0.0"
"scheduler": "running" if scheduler.running else "stopped",
"version": "1.3.0"
}
except Exception as e:
logger.error(f"健康检查失败: {e}")
@@ -191,20 +221,23 @@ async def health_check():
"error": str(e)
}
@app.get("/api/stats")
async def get_stats(_permission: str = optional_auth()):
async def get_stats():
try:
db = SQLiteManager()
stats = await db.get_stats()
today_new = await db.get_today_new_count()
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:
logger.error(f"获取统计信息失败: {e}")
return {"code": 500, "message": "获取统计信息失败呢~", "data": None}
return {"code": 500, "message": "获取统计信息失败", "data": None}
@app.post("/api/proxies")
async def get_proxies(request: ProxyRequest, _permission: str = optional_auth()):
async def get_proxies(request: ProxyRequest):
try:
db = SQLiteManager()
proxies = await db.get_proxies_paginated(
@@ -234,7 +267,7 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth())
return {
"code": 200,
"message": "获取代理列表成功啦~",
"message": "获取代理列表成功",
"data": {
"list": proxy_list,
"total": total,
@@ -244,16 +277,17 @@ async def get_proxies(request: ProxyRequest, _permission: str = optional_auth())
}
except Exception as e:
logger.error(f"获取代理列表失败: {e}")
return {"code": 500, "message": "获取代理列表失败呢~", "data": None}
return {"code": 500, "message": "获取代理列表失败", "data": None}
@app.get("/api/proxies/random")
async def get_random_proxy(_permission: str = optional_auth()):
async def get_random_proxy():
db = SQLiteManager()
proxy = await db.get_random_proxy()
if proxy:
return {
"code": 200,
"message": "获取随机代理成功啦~",
"message": "获取随机代理成功",
"data": {
"ip": proxy[0],
"port": proxy[1],
@@ -262,18 +296,19 @@ async def get_random_proxy(_permission: str = optional_auth()):
"last_check": format_datetime(proxy[4])
}
}
return {"code": 404, "message": "没有找到可用的代理呢~", "data": None}
return {"code": 404, "message": "没有找到可用的代理", "data": None}
@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:
db = SQLiteManager()
if format not in ['csv', 'txt', 'json']:
raise HTTPException(status_code=400, detail="不支持的导出格式呢~")
raise HTTPException(status_code=400, detail="不支持的导出格式")
if limit > 100000:
raise HTTPException(status_code=400, detail="导出数量不能超过100000条呢~")
raise HTTPException(status_code=400, detail="导出数量不能超过100000条")
async def generate_csv():
proxies = await db.get_all_proxies()
@@ -342,16 +377,17 @@ async def export_proxies(format: str, protocol: Optional[str] = None, _permissio
raise
except Exception as e:
logger.error(f"导出代理失败: {e}")
raise HTTPException(status_code=500, detail="导出代理失败呢~")
raise HTTPException(status_code=500, detail="导出代理失败")
@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()
proxy = await db.get_proxy_detail(ip, port)
if proxy:
return {
"code": 200,
"message": "获取代理详情成功啦~",
"message": "获取代理详情成功",
"data": {
"ip": proxy[0],
"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])
}
}
raise HTTPException(status_code=404, detail="代理不存在呢~")
raise HTTPException(status_code=404, detail="代理不存在")
@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()
await db.delete_proxy(ip, port)
return {"code": 200, "message": "删除代理成功啦~", "data": None}
return {"code": 200, "message": "删除代理成功", "data": None}
@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()
proxy_tuples = [(item.ip, item.port) for item in request.proxies]
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")
async def clean_invalid_proxies(_permission: str = Depends(require_admin)):
async def clean_invalid_proxies():
db = SQLiteManager()
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")
async def get_plugins(_permission: str = optional_auth()):
async def get_plugins():
try:
plugins_info = plugin_manager.get_all_plugin_info()
return {
"code": 200,
"message": "获取插件列表成功啦~",
"message": "获取插件列表成功",
"data": {
"plugins": plugins_info
}
}
except Exception as e:
logger.error(f"获取插件列表失败: {e}")
return {"code": 500, "message": "获取插件列表失败呢~", "data": None}
return {"code": 500, "message": "获取插件列表失败", "data": None}
class PluginToggleRequest(BaseModel):
enabled: bool
@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:
success = plugin_manager.toggle_plugin(plugin_id, request.enabled)
if success:
return {
"code": 200,
"message": f"插件 {plugin_id}{'启用' if request.enabled else '禁用'}啦~",
"message": f"插件 {plugin_id}{'启用' if request.enabled else '禁用'}",
"data": {
"plugin_id": plugin_id,
"enabled": request.enabled
}
}
else:
return {"code": 404, "message": "插件不存在呢~", "data": None}
return {"code": 404, "message": "插件不存在", "data": None}
except Exception as e:
logger.error(f"切换插件状态失败: {e}")
return {"code": 500, "message": "切换插件状态失败呢~", "data": None}
return {"code": 500, "message": "切换插件状态失败", "data": None}
@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:
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()
# 1. 执行爬取
results = await plugin_manager.run_plugin(plugin_id)
for ip, port, protocol in results:
await db.insert_proxy(ip, port, protocol)
if not results:
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 {
"code": 200,
"message": f"插件 {plugin_id} 开始爬取啦~",
"message": f"插件 {plugin_id} 爬取并验证完成",
"data": {
"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:
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__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8923)
uvicorn.run(app, host=config.HOST, port=config.PORT)

View File

@@ -11,7 +11,7 @@ class Config:
# API服务配置
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"))
@@ -49,11 +49,6 @@ class Config:
# CORS配置
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
def get(cls, key: str, default=None):
"""获取配置项"""

View File

@@ -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
View 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()

View File

@@ -1,12 +1,16 @@
import asyncio
import aiohttp
import aiohttp_socks
import random
import time
from core.log import logger
class ProxyValidator:
"""代理验证器 - 支持 HTTP/HTTPS/SOCKS4/SOCKS5"""
def __init__(self, max_concurrency=50, timeout=5):
# 验证目标源(使用更适合代理验证的源)
# 验证目标源
self.http_sources = [
"http://httpbin.org/ip",
"http://api.ipify.org"
@@ -20,57 +24,169 @@ class ProxyValidator:
self.session = None
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
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口"""
if self.session:
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()
sources = self.https_sources if protocol == 'https' else self.http_sources
test_url = random.choice(sources)
# aiohttp 代理 URL 格式
proxy_url = f"http://{ip}:{port}"
test_url = self._get_test_url(protocol)
async with self.semaphore:
start_time = time.time()
try:
# 复用 session
async with self.session.get(
test_url,
proxy=proxy_url,
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
if protocol in ('socks4', 'socks5'):
return await self._validate_socks(ip, port, protocol, test_url, start_time)
else:
return await self._validate_http(ip, port, protocol, test_url, start_time)
except asyncio.TimeoutError:
logger.warning(f"验证超时: {ip}:{port} ({protocol})")
return False, 0
except Exception as e:
logger.warning(f"验证失败: {ip}:{port} ({protocol}) - {e}")
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)

View File

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

View File

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

View File

@@ -1,175 +1,256 @@
<script setup>
import { RouterView, useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const activeMenu = computed(() => route.path)
</script>
<template>
<div class="app-container">
<el-menu
:default-active="activeMenu"
class="side-menu"
router
>
<aside class="sidebar">
<div class="logo-section">
<div class="logo">🌸</div>
<div class="logo-text">代理池</div>
<el-icon class="logo" :size="40"><Grid /></el-icon>
<h1 class="logo-text">代理池</h1>
</div>
<el-menu-item index="/dashboard">
<template #title>
<span class="menu-icon">🏠</span>
<span>总览</span>
</template>
</el-menu-item>
<nav class="menu-nav">
<router-link
v-for="item in menuItems"
:key="item.index"
:to="item.index"
:class="['menu-item', { active: isActive(item.index) }]"
>
<el-icon class="menu-icon" :size="18">
<component :is="item.icon" />
</el-icon>
<span class="menu-label">{{ item.label }}</span>
</router-link>
</nav>
</aside>
<el-menu-item index="/proxies">
<template #title>
<span class="menu-icon">📋</span>
<span>代理列表</span>
</template>
</el-menu-item>
<el-menu-item index="/crawler">
<template #title>
<span class="menu-icon">🎀</span>
<span>任务管理</span>
</template>
</el-menu-item>
<el-menu-item index="/plugins">
<template #title>
<span class="menu-icon">🔌</span>
<span>插件管理</span>
</template>
</el-menu-item>
<el-menu-item index="/settings">
<template #title>
<span class="menu-icon"></span>
<span>设置</span>
</template>
</el-menu-item>
</el-menu>
<div class="main-content">
<RouterView />
</div>
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import {
House,
Document,
Connection,
Setting,
Grid
} from '@element-plus/icons-vue'
const route = useRoute()
const menuItems = [
{ index: '/dashboard', icon: House, label: '总览' },
{ index: '/proxies', icon: Document, label: '代理列表' },
{ index: '/plugins', icon: Connection, label: '插件管理' },
{ index: '/settings', icon: Setting, label: '设置' }
]
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
</script>
<style scoped>
.app-container {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--bg);
}
.side-menu {
width: 240px;
/* 侧边栏 - 冷灰紫风格 */
.sidebar {
width: 220px;
height: 100%;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
box-shadow: 4px 0 20px rgba(255, 107, 157, 0.1);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
z-index: 10;
z-index: 100;
display: flex;
flex-direction: column;
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 35px 0;
padding: 24px 20px;
border-bottom: 1px solid var(--border);
position: relative;
}
.logo-section::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--primary), transparent);
animation: shimmer 3s infinite;
}
.logo {
font-size: 52px;
margin-bottom: 10px;
animation: float 3s ease-in-out infinite;
color: var(--primary);
margin-right: 12px;
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.4));
}
.logo-text {
font-size: 22px;
font-weight: 700;
color: var(--primary);
text-shadow: 0 0 20px rgba(255, 107, 157, 0.3);
letter-spacing: 2px;
}
.menu-icon {
font-size: 20px;
margin-right: 12px;
}
:deep(.el-menu) {
border-right: none;
background-color: transparent;
padding: 20px 0;
}
:deep(.el-menu-item) {
border-radius: 12px;
margin: 8px 12px;
transition: var(--transition-hover);
color: var(--text-secondary);
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 1px;
margin: 0;
}
.menu-nav {
flex: 1;
padding: 16px 12px;
overflow-y: auto;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin: 4px 0;
border-radius: var(--radius-md);
color: var(--text-secondary);
font-weight: 500;
text-decoration: none;
transition: var(--transition-base);
position: relative;
overflow: hidden;
}
:deep(.el-menu-item::before) {
/* 悬停状态 */
.menu-item:hover {
background: var(--surface-2);
color: var(--primary);
}
/* 激活状态 - 紫底 + 左条 */
.menu-item.active {
background: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.menu-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--primary);
transform: scaleY(0);
transition: transform 0.3s ease;
border-radius: 0 2px 2px 0;
}
:deep(.el-menu-item:hover) {
background: rgba(0, 212, 255, 0.1) !important;
color: var(--primary);
transform: translateX(8px);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2);
.menu-icon {
margin-right: 12px;
flex-shrink: 0;
}
:deep(.el-menu-item:hover::before) {
transform: scaleY(1);
}
:deep(.el-menu-item.is-active) {
background: var(--gradient-cyan) !important;
color: var(--bg-page) !important;
font-weight: 700;
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.4);
}
:deep(.el-menu-item.is-active::before) {
transform: scaleY(1);
.menu-label {
white-space: nowrap;
font-size: 14px;
}
/* 主内容区 */
.main-content {
flex: 1;
overflow-y: auto;
background: var(--bg-page);
background: var(--bg);
min-width: 0;
}
/* 页面过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* 响应式 - 平板 */
@media (max-width: 768px) {
.sidebar {
width: 64px;
}
.logo-text,
.menu-label {
display: none;
}
.logo-section {
justify-content: center;
padding: 20px 0;
}
.logo {
margin-right: 0;
}
.menu-item {
justify-content: center;
padding: 12px;
margin: 4px 8px;
}
.menu-icon {
margin-right: 0;
}
}
/* 响应式 - 手机 */
@media (max-width: 480px) {
.sidebar {
width: 100%;
height: auto;
position: fixed;
bottom: 0;
left: 0;
z-index: 1000;
border-right: none;
border-top: 1px solid var(--border);
flex-direction: row;
padding: 0;
}
.logo-section {
display: none;
}
.menu-nav {
display: flex;
justify-content: space-around;
padding: 8px 0;
width: 100%;
flex-direction: row;
}
.menu-item {
flex-direction: column;
margin: 0;
padding: 8px 12px;
font-size: 12px;
}
.menu-item::before {
display: none;
}
.menu-icon {
margin-right: 0;
margin-bottom: 4px;
}
.main-content {
padding-bottom: 70px;
}
}
</style>

View File

@@ -1,73 +1,160 @@
import axios from 'axios'
import { showError } from '../utils/message'
/** @type {string} 默认 API 基础 URL */
export const DEFAULT_API_BASE_URL = 'http://localhost:9949'
/** @type {number} 请求超时时间(毫秒) */
export const REQUEST_TIMEOUT = 30000
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8923',
timeout: 30000
baseURL: import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL,
timeout: REQUEST_TIMEOUT
})
api.interceptors.request.use(
config => {
const apiKey = localStorage.getItem('api_key')
if (apiKey) {
config.headers['X-API-Key'] = apiKey
/**
* 从 Blob 解析 JSON 错误响应
* @param {Blob} blob
* @returns {Promise<object|null>}
*/
async function parseBlobError(blob) {
try {
const text = await blob.text()
return JSON.parse(text)
} catch {
return null
}
return config
},
error => {
return Promise.reject(error)
}
)
api.interceptors.response.use(
response => response.data,
error => {
(response) => response.data,
async (error) => {
// 处理 Blob 类型的错误响应
if (error.response?.data instanceof Blob) {
const parsedData = await parseBlobError(error.response.data)
if (parsedData) {
error.response.data = parsedData
}
}
console.error('API请求错误:', error)
showError(error)
return Promise.reject(error)
}
)
/**
* 清理请求参数,移除 null/undefined/空字符串
* @param {object} params
* @returns {object}
*/
function cleanParams(params) {
const cleaned = {}
Object.keys(params).forEach((key) => {
const value = params[key]
if (value !== null && value !== undefined && value !== '') {
cleaned[key] = value
}
})
return cleaned
}
/**
* 生成请求配置,支持 AbortSignal
* @param {AbortSignal} [signal]
* @returns {object}
*/
function createRequestConfig(signal) {
return signal ? { signal } : {}
}
// ==================== API 模块 ====================
export const statsAPI = {
/** @returns {Promise<import('./types').ApiResponse<import('./types').StatsData>>} */
getStats: () => api.get('/api/stats')
}
export const proxiesAPI = {
getProxies: (params) => {
const cleanedParams = {}
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
cleanedParams[key] = params[key]
}
})
return api.post('/api/proxies', cleanedParams)
},
getRandomProxy: () => api.get('/api/proxies/random'),
getProxyDetail: (ip, port) => api.get(`/api/proxies/${ip}/${port}`),
/**
* @param {object} params
* @param {AbortSignal} [signal]
* @returns {Promise<import('./types').ApiResponse<import('./types').ProxyListData>>}
*/
getProxies: (params, signal) =>
api.post('/api/proxies', cleanParams(params), createRequestConfig(signal)),
/**
* @param {string} ip
* @param {number|string} port
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
deleteProxy: (ip, port) => api.delete(`/api/proxies/${ip}/${port}`),
/**
* @param {Array<[string, number|string]>} proxies
* @returns {Promise<import('./types').ApiResponse<{deleted_count: number}>>}
*/
batchDeleteProxies: (proxies) => api.post('/api/proxies/batch-delete', { proxies }),
/** @returns {Promise<import('./types').ApiResponse<{deleted_count: number}>>} */
cleanInvalidProxies: () => api.delete('/api/proxies/clean-invalid'),
/**
* @param {string} format
* @param {string|null} protocol
* @returns {Promise<Blob>}
*/
exportProxies: (format, protocol) => api.get(`/api/proxies/export/${format}`, {
params: protocol ? { protocol } : {},
responseType: 'blob'
})
}
export const crawlerAPI = {
start: (numValidators = 50) => api.post('/api/crawler/start', { num_validators: numValidators }),
stop: () => api.post('/api/crawler/stop'),
getStatus: () => api.get('/api/crawler/status')
export const pluginsAPI = {
/** @returns {Promise<import('./types').ApiResponse<{plugins: import('./types').Plugin[] }>>} */
getPlugins: () => api.get('/api/plugins'),
/**
* @param {string|number} pluginId
* @param {boolean} enabled
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
/**
* @param {string|number} pluginId
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`),
/** @returns {Promise<import('./types').ApiResponse<any>>} */
crawlAll: () => api.post('/api/plugins/crawl-all')
}
export const schedulerAPI = {
setScheduler: (enabled, intervalMinutes = 60) => api.post('/api/scheduler', { enabled, interval_minutes: intervalMinutes }),
getStatus: () => api.get('/api/scheduler')
/** @returns {Promise<import('./types').ApiResponse<{running: boolean}>>} */
start: () => api.post('/api/scheduler/start'),
/** @returns {Promise<import('./types').ApiResponse<{running: boolean}>>} */
stop: () => api.post('/api/scheduler/stop'),
/** @returns {Promise<import('./types').ApiResponse<{started: boolean}>>} */
validateNow: () => api.post('/api/scheduler/validate-now'),
/** @returns {Promise<import('./types').ApiResponse<{running: boolean, interval_minutes: number}>>} */
getStatus: () => api.get('/api/scheduler/status')
}
export const pluginsAPI = {
getPlugins: () => api.get('/api/plugins'),
togglePlugin: (pluginId, enabled) => api.put(`/api/plugins/${pluginId}/toggle`, { enabled }),
crawlPlugin: (pluginId) => api.post(`/api/plugins/${pluginId}/crawl`)
export const settingsAPI = {
/** @returns {Promise<import('./types').ApiResponse<import('./types').SettingsData>>} */
getSettings: () => api.get('/api/settings'),
/**
* @param {object} data
* @returns {Promise<import('./types').ApiResponse<any>>}
*/
saveSettings: (data) => api.post('/api/settings', data)
}
export default api

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

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

View File

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

View File

@@ -1,18 +1,35 @@
<template>
<el-card class="header-card" shadow="hover">
<h1 class="title">{{ icon }} {{ title }} {{ icon }}</h1>
<h1 class="title">
<el-icon v-if="icon" :size="24" class="title-icon">
<component :is="icon" />
</el-icon>
<span class="title-text">{{ title }}</span>
</h1>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
</el-card>
</template>
<script setup>
/**
* 页面标题组件 - 冷灰紫主题
* @description 统一的页面头部展示
*/
defineProps({
/** 页面标题 */
title: {
type: String,
required: true
},
/** 图标组件或组件名称 */
icon: {
type: [String, Object],
default: null
},
/** 副标题 */
subtitle: {
type: String,
default: '📄'
default: ''
}
})
</script>
@@ -20,15 +37,45 @@ defineProps({
<style scoped>
.header-card {
margin-bottom: 20px;
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.header-card:hover {
border-color: var(--border-light);
}
.title {
text-align: center;
display: flex;
align-items: center;
gap: 10px;
margin: 0;
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.5px;
}
.title-icon {
color: var(--primary);
font-size: 28px;
font-weight: 700;
letter-spacing: 2px;
flex-shrink: 0;
filter: drop-shadow(0 0 8px rgba(146, 124, 255, 0.3));
}
.title-text {
line-height: 1.2;
}
.subtitle {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 768px) {
.title {
font-size: 18px;
}
}
</style>

View File

@@ -2,18 +2,28 @@
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">📈 协议分布</span>
<span class="card-title">
<el-icon class="header-icon"><PieChart /></el-icon>
协议分布
</span>
<el-tooltip content="显示各协议类型的代理数量分布">
<el-icon class="help-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</template>
<div ref="chartRef" class="chart-container"></div>
<div ref="chartRef" class="chart-container" v-loading="!hasData">
<el-empty v-if="!hasData" description="暂无数据" :image-size="80" />
</div>
</el-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { InfoFilled, PieChart } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
const props = defineProps({
/** 统计数据 */
data: {
type: Object,
default: () => ({})
@@ -22,17 +32,51 @@ const props = defineProps({
const chartRef = ref(null)
let chartInstance = null
let resizeTimer = null
const cachedColors = ref(null)
const chartData = computed(() => [
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: '#00D4FF' } },
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: '#00A8CC' } },
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: '#7B68EE' } },
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: '#FF3366' } }
])
// ==================== 计算属性 ====================
const hasData = computed(() => {
const { http_count, https_count, socks4_count, socks5_count } = props.data
return (http_count || 0) + (https_count || 0) + (socks4_count || 0) + (socks5_count || 0) > 0
})
const total = computed(() => chartData.value.reduce((sum, item) => sum + item.value, 0))
const chartData = computed(() => {
if (!cachedColors.value) return []
const colors = cachedColors.value
return [
{ value: props.data.http_count || 0, name: 'HTTP', itemStyle: { color: colors.info } },
{ value: props.data.https_count || 0, name: 'HTTPS', itemStyle: { color: colors.success } },
{ value: props.data.socks4_count || 0, name: 'SOCKS4', itemStyle: { color: colors.primary } },
{ value: props.data.socks5_count || 0, name: 'SOCKS5', itemStyle: { color: colors.warning } }
].filter(item => item.value > 0)
})
const total = computed(() =>
chartData.value.reduce((sum, item) => sum + item.value, 0)
)
// ==================== 方法 ====================
function loadColors() {
if (cachedColors.value) return cachedColors.value
const getCssVar = (name, fallback) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
cachedColors.value = {
primary: getCssVar('--primary', '#927CFF'),
success: getCssVar('--success', '#22C55E'),
warning: getCssVar('--warning', '#F59E0B'),
info: getCssVar('--info', '#38BDF8'),
textPrimary: getCssVar('--text-primary', '#F5F7FA'),
textSecondary: getCssVar('--text-secondary', '#A5AEBD'),
surface: getCssVar('--surface', '#181C25')
}
return cachedColors.value
}
function getChartOption() {
const colors = cachedColors.value
return {
tooltip: {
trigger: 'item',
@@ -40,11 +84,11 @@ function getChartOption() {
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${percent}%)`
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#FF6B9D',
backgroundColor: 'rgba(24, 28, 37, 0.95)',
borderColor: colors.primary,
borderWidth: 1,
textStyle: {
color: '#333',
color: colors.textPrimary,
fontSize: 14
}
},
@@ -53,20 +97,20 @@ function getChartOption() {
right: 10,
top: 'center',
textStyle: {
color: '#666',
fontSize: 14
color: colors.textSecondary,
fontSize: 13
},
itemGap: 20
itemGap: 16
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['35%', '50%'],
radius: ['40%', '65%'],
center: ['38%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#FFFFFF',
borderRadius: 6,
borderColor: colors.surface,
borderWidth: 2
},
label: {
@@ -75,15 +119,15 @@ function getChartOption() {
emphasis: {
label: {
show: true,
fontSize: 18,
fontSize: 16,
fontWeight: 'bold',
color: '#333',
color: colors.textPrimary,
formatter: '{b}\n{c}个'
},
itemStyle: {
shadowBlur: 8,
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 107, 157, 0.2)'
shadowColor: 'rgba(146, 124, 255, 0.3)'
}
},
animationType: 'scale',
@@ -96,8 +140,9 @@ function getChartOption() {
}
function initChart() {
if (!chartRef.value) return
if (!chartRef.value || !hasData.value) return
loadColors()
chartInstance = echarts.init(chartRef.value)
updateChart()
@@ -105,35 +150,79 @@ function initChart() {
}
function updateChart() {
if (!chartInstance) return
if (!chartInstance || !hasData.value) return
chartInstance.setOption(getChartOption(), true)
}
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
chartInstance?.resize()
}, 200)
}
function destroyChart() {
window.removeEventListener('resize', handleResize)
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
chartInstance?.dispose()
chartInstance = null
}
// ==================== 监听 ====================
watch(() => props.data, () => {
if (!chartInstance && hasData.value) {
initChart()
} else {
updateChart()
}
}, { deep: true })
// ==================== 生命周期 ====================
onMounted(() => {
if (hasData.value) {
initChart()
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
destroyChart()
})
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
min-height: 400px;
background: var(--surface);
border: 1px solid var(--border);
}
.chart-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.help-icon {
color: var(--text-muted);
cursor: help;
transition: var(--transition-base);
}
.help-icon:hover {
color: var(--primary);
}
.chart-container {
height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,83 +1,144 @@
<template>
<el-card class="chart-card" shadow="hover">
<el-card class="actions-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">🎯 快速操作</span>
<span class="card-title">
<el-icon class="header-icon"><Lightning /></el-icon>
快速操作
</span>
</div>
</template>
<div class="quick-actions">
<el-button
type="primary"
size="large"
class="action-btn"
:loading="loading"
@click="$emit('startCrawler')"
>
<span class="btn-icon">🚀</span>
立即更新
</el-button>
<el-button
type="success"
size="large"
class="action-btn"
<button
class="action-btn btn-success"
@click="$emit('export')"
>
<span class="btn-icon">📥</span>
导出代理
</el-button>
<el-button
type="warning"
size="large"
class="action-btn"
<span class="btn-content">
<el-icon class="btn-icon"><Download /></el-icon>
<span class="btn-text">导出代理</span>
</span>
</button>
<button
class="action-btn btn-warning"
@click="$emit('clean')"
>
<span class="btn-icon">🧹</span>
清理无效
</el-button>
<span class="btn-content">
<el-icon class="btn-icon"><Delete /></el-icon>
<span class="btn-text">清理无效</span>
</span>
</button>
</div>
</el-card>
</template>
<script setup>
defineProps({
loading: {
type: Boolean,
default: false
}
})
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
defineEmits(['start-crawler', 'export', 'clean'])
defineEmits(['export', 'clean'])
</script>
<style scoped>
.chart-card {
border-radius: var(--radius-xl);
.actions-card {
border-radius: var(--radius-lg);
min-height: 400px;
background: var(--surface);
border: 1px solid var(--border);
}
.actions-card:hover {
border-color: var(--border-light);
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 15px;
padding: 20px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.action-btn {
width: 100%;
height: 60px;
font-size: 16px;
border-radius: 14px;
font-weight: 700;
letter-spacing: 0.5px;
box-shadow: var(--shadow-md);
height: 56px;
border: none;
border-radius: var(--radius-md);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: grid;
place-items: center;
}
.action-btn:hover {
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.25);
transform: translateY(-5px) scale(1.02);
.btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-icon {
font-size: 20px;
font-size: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.btn-text {
display: inline-block;
text-align: center;
min-width: 64px;
}
/* 按钮样式 */
.btn-success {
background: var(--success);
color: #0F1117;
}
.btn-success:hover {
background: #2DD4BF;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
}
.btn-warning {
background: var(--warning);
color: #0F1117;
}
.btn-warning:hover {
background: #FBBF24;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
@media (max-width: 768px) {
.quick-actions {
grid-template-columns: repeat(2, 1fr);
padding: 12px;
gap: 10px;
}
.action-btn {
height: 44px;
font-size: 14px;
}
.btn-text {
min-width: auto;
}
}
@media (max-width: 480px) {
.quick-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,48 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { pluginsAPI } from '../api'
/**
* Plugins Store
* 管理插件列表和状态
*/
export const usePluginsStore = defineStore('plugins', () => {
// ==================== State ====================
const plugins = ref([])
const loading = ref(false)
// ==================== Getters ====================
const enabledCount = computed(() => plugins.value.filter(p => p.enabled).length)
const totalCount = computed(() => plugins.value.length)
// ==================== Actions ====================
/**
* 获取插件列表
* @returns {Promise<boolean>}
*/
async function fetchPlugins() {
loading.value = true
try {
const response = await pluginsAPI.getPlugins()
if (response.code === 200) {
plugins.value = response.data.plugins || []
return true
}
} catch (error) {
console.error('获取插件列表失败:', error)
} finally {
loading.value = false
}
return false
}
/**
* 切换插件启用状态
* @param {string|number} pluginId
* @param {boolean} enabled
* @returns {Promise<boolean>}
*/
async function togglePlugin(pluginId, enabled) {
try {
const response = await pluginsAPI.togglePlugin(pluginId, enabled)
@@ -36,23 +59,49 @@ export const usePluginsStore = defineStore('plugins', () => {
return false
}
/**
* 触发插件爬取
* @param {string|number} pluginId
* @returns {Promise<boolean>}
*/
async function crawlPlugin(pluginId) {
try {
const response = await pluginsAPI.crawlPlugin(pluginId)
if (response.code === 200) {
return true
}
return response.code === 200
} catch (error) {
console.error('触发插件爬取失败:', error)
}
return false
}
}
/**
* 根据 ID 获取插件
* @param {string|number} id
* @returns {object|undefined}
*/
function getPluginById(id) {
return plugins.value.find(p => p.id === id)
}
/**
* 重置状态
*/
function reset() {
plugins.value = []
}
return {
// State
plugins,
loading,
// Getters
enabledCount,
totalCount,
// Actions
fetchPlugins,
togglePlugin,
crawlPlugin
crawlPlugin,
getPluginById,
reset
}
})

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
/* ==================== Element Plus 冷灰紫主题覆盖 ==================== */
/* -------------------- 输入框 -------------------- */
.el-input__wrapper {
background-color: var(--surface-3) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
border-radius: var(--radius-md) !important;
}
.el-input__wrapper:hover,
@@ -7,8 +12,23 @@
box-shadow: 0 0 0 1px var(--primary) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important;
}
.el-input__inner {
color: var(--text-primary) !important;
}
.el-input__inner::placeholder {
color: var(--text-muted) !important;
}
/* -------------------- 选择器 -------------------- */
.el-select__wrapper {
background-color: var(--surface-3) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
border-radius: var(--radius-md) !important;
}
.el-select__wrapper:hover,
@@ -16,55 +36,67 @@
box-shadow: 0 0 0 1px var(--primary) inset !important;
}
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--primary) inset, var(--shadow-primary-sm) !important;
}
.el-select__placeholder {
color: var(--text-secondary) !important;
color: var(--text-muted) !important;
}
.el-select__caret {
color: var(--text-secondary) !important;
}
.el-select__caret.is-reverse {
color: var(--primary) !important;
}
.el-select-dropdown {
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-md) !important;
background: white !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
color: var(--text-secondary) !important;
}
.el-select-dropdown__item:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
}
.el-select-dropdown__item.is-selected {
color: var(--primary) !important;
font-weight: 600;
background: var(--primary-soft) !important;
}
/* -------------------- 数字输入框 -------------------- */
.el-input-number__decrease,
.el-input-number__increase {
background: var(--bg-light) !important;
background: var(--surface-2) !important;
color: var(--text-secondary) !important;
border: 1px solid var(--border) !important;
}
.el-input-number__decrease:hover,
.el-input-number__increase:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
border-color: var(--primary) !important;
}
.el-input-number__decrease.is-disabled,
.el-input-number__increase.is-disabled {
color: #ccc !important;
color: var(--el-disabled-text) !important;
border-color: var(--border) !important;
}
.el-input-number__wrapper {
background-color: var(--surface-3) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
}
@@ -73,79 +105,132 @@
box-shadow: 0 0 0 1px var(--primary) inset !important;
}
/* -------------------- 按钮 -------------------- */
.el-button {
border: 1px solid var(--border) !important;
background: var(--surface-2) !important;
color: var(--text-secondary) !important;
border-radius: var(--radius-md) !important;
font-weight: 500;
}
.el-button--primary {
background: var(--gradient-primary) !important;
.el-button:hover {
border-color: var(--primary) !important;
color: var(--primary) !important;
background: var(--surface-3) !important;
}
/* 主要按钮 - 深紫实心 */
.el-button--primary {
background: var(--primary-solid) !important;
border-color: var(--primary-solid) !important;
color: white !important;
}
.el-button--primary:hover {
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3) !important;
transform: translateY(-2px);
background: var(--primary-solid-hover) !important;
border-color: var(--primary-solid-hover) !important;
box-shadow: var(--shadow-primary-md) !important;
transform: translateY(-1px);
}
/* 成功按钮 - 青绿 */
.el-button--success {
background: var(--gradient-cyan) !important;
border-color: var(--cyan) !important;
color: white !important;
background: var(--success) !important;
border-color: var(--success) !important;
color: var(--bg) !important;
}
.el-button--success:hover {
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3) !important;
transform: translateY(-2px);
background: #2DD4BF !important;
border-color: #2DD4BF !important;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3) !important;
}
/* 警告按钮 - 橙黄 */
.el-button--warning {
background: var(--gradient-yellow) !important;
border-color: var(--yellow) !important;
color: white !important;
background: var(--warning) !important;
border-color: var(--warning) !important;
color: var(--bg) !important;
}
.el-button--warning:hover {
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3) !important;
transform: translateY(-2px);
background: #FBBF24 !important;
border-color: #FBBF24 !important;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
}
/* 危险按钮 - 粉红 */
.el-button--danger {
background: var(--gradient-danger) !important;
background: var(--danger) !important;
border-color: var(--danger) !important;
color: white !important;
}
.el-button--danger:hover {
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3) !important;
transform: translateY(-2px);
background: #FCA5A5 !important;
border-color: #FCA5A5 !important;
box-shadow: 0 0 20px rgba(251, 113, 133, 0.3) !important;
}
/* 纯文字按钮 */
.el-button--text {
background: transparent !important;
border-color: transparent !important;
color: var(--primary) !important;
}
.el-button--text:hover {
color: var(--primary-hover) !important;
background: var(--primary-soft) !important;
}
/* -------------------- 卡片 -------------------- */
.el-card {
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-sm) !important;
box-shadow: none !important;
background: var(--surface) !important;
border-radius: var(--radius-lg) !important;
}
.el-card:hover {
border-color: var(--border-light) !important;
}
.el-card__header {
border-bottom: 1px solid var(--border) !important;
padding: 16px 20px;
}
.el-card__body {
background: var(--bg-card) !important;
background: transparent !important;
padding: 20px;
}
/* -------------------- 表格 -------------------- */
.el-table {
border: 1px solid var(--border) !important;
background: white !important;
background: var(--surface) !important;
border-radius: var(--radius-lg) !important;
--el-table-row-hover-bg-color: var(--surface-2);
--el-table-current-row-bg-color: var(--primary-soft);
--el-table-header-bg-color: var(--surface-2);
--el-table-tr-bg-color: var(--surface);
--el-table-expanded-cell-bg-color: var(--surface);
}
.el-table th.el-table__cell {
background: var(--bg-light) !important;
color: var(--text-primary) !important;
background: var(--surface-2) !important;
color: var(--text-secondary) !important;
border-bottom: 1px solid var(--border) !important;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el-table td.el-table__cell {
color: var(--text-primary) !important;
border-bottom: 1px solid var(--border) !important;
}
@@ -158,16 +243,22 @@
}
.el-table tr:hover > td {
background: #FFF0F5 !important;
background: var(--surface-2) !important;
}
.el-table__body tr.current-row > td.el-table__cell {
background: var(--border) !important;
background: var(--primary-soft) !important;
}
/* 表格行选中左侧高亮条 */
.el-table__body tr.current-row > td.el-table__cell:first-child {
border-left: 3px solid var(--primary) !important;
}
/* -------------------- 复选框 -------------------- */
.el-checkbox__inner {
border: 1px solid var(--border) !important;
background: white !important;
background: var(--surface-3) !important;
}
.el-checkbox__inner:hover {
@@ -180,61 +271,86 @@
}
.el-checkbox__input.is-disabled .el-checkbox__inner {
background: #f5f5f5 !important;
border-color: #e4e7ed !important;
background: var(--el-disabled-bg) !important;
border-color: var(--el-disabled-border) !important;
}
/* -------------------- 分页 -------------------- */
.el-pagination button {
border: 1px solid var(--border) !important;
background: var(--bg-light) !important;
background: var(--surface) !important;
color: var(--text-secondary) !important;
border-radius: var(--radius-sm) !important;
}
.el-pagination button:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--surface-2) !important;
border-color: var(--primary) !important;
color: var(--primary) !important;
}
.el-pagination li.is-active {
background: var(--primary) !important;
color: white !important;
border-color: var(--primary) !important;
.el-pagination button:disabled {
background: var(--surface) !important;
color: var(--text-muted) !important;
border-color: var(--border) !important;
}
.el-pager li {
background: var(--bg-light) !important;
background: var(--surface) !important;
color: var(--text-secondary) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-sm) !important;
}
.el-pager li:hover {
color: var(--primary) !important;
border-color: var(--primary) !important;
}
.el-pager li.is-active {
background: var(--primary) !important;
color: var(--bg) !important;
border-color: var(--primary) !important;
font-weight: 600;
}
/* -------------------- 标签 -------------------- */
.el-tag {
border-radius: var(--radius-sm) !important;
font-weight: 500;
}
.el-tag--primary {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
border-color: rgba(255, 107, 157, 0.3) !important;
border-color: rgba(146, 124, 255, 0.3) !important;
}
.el-tag--success {
background: rgba(0, 212, 255, 0.1) !important;
color: var(--cyan) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
background: var(--success-soft) !important;
color: var(--success) !important;
border-color: rgba(34, 197, 94, 0.3) !important;
}
.el-tag--warning {
background: rgba(255, 184, 0, 0.1) !important;
color: var(--yellow) !important;
border-color: rgba(255, 184, 0, 0.3) !important;
background: var(--warning-soft) !important;
color: var(--warning) !important;
border-color: rgba(245, 158, 11, 0.3) !important;
}
.el-tag--danger {
background: rgba(255, 107, 107, 0.1) !important;
background: var(--danger-soft) !important;
color: var(--danger) !important;
border-color: rgba(255, 107, 107, 0.3) !important;
border-color: rgba(251, 113, 133, 0.3) !important;
}
.el-tag--info {
background: var(--info-soft) !important;
color: var(--info) !important;
border-color: rgba(56, 189, 248, 0.3) !important;
}
/* -------------------- 评分 -------------------- */
.el-rate__icon {
color: var(--border) !important;
}
@@ -243,36 +359,54 @@
color: var(--primary) !important;
}
/* -------------------- 对话框 -------------------- */
.el-dialog {
border: 1px solid var(--border) !important;
background: var(--surface) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-xl) !important;
}
.el-dialog__header {
border-bottom: 1px solid var(--border) !important;
padding: 16px 20px;
margin: 0;
}
.el-dialog__title {
color: var(--text-primary) !important;
font-weight: 600;
}
.el-dialog__body {
background: white !important;
background: transparent !important;
color: var(--text-secondary) !important;
padding: 20px;
}
.el-dialog__footer {
border-top: 1px solid var(--border) !important;
padding: 16px 20px;
}
/* -------------------- 下拉菜单 -------------------- */
.el-dropdown-menu {
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-md) !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-dropdown-menu__item {
color: var(--text-primary) !important;
color: var(--text-secondary) !important;
}
.el-dropdown-menu__item:hover {
background: rgba(255, 107, 157, 0.1) !important;
background: var(--primary-soft) !important;
color: var(--primary) !important;
}
/* -------------------- 滚动条 -------------------- */
.el-scrollbar__wrap::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -287,46 +421,54 @@
background: var(--primary);
}
/* -------------------- 表单 -------------------- */
.el-form-item__label {
color: var(--text-muted) !important;
color: var(--text-secondary) !important;
font-weight: 500;
}
.el-form-item__error {
color: var(--danger) !important;
}
/* -------------------- 消息提示 -------------------- */
.el-message {
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-md) !important;
box-shadow: var(--shadow-lg) !important;
background: var(--surface) !important;
border-radius: var(--radius-md) !important;
}
.el-message--success {
background: rgba(0, 212, 255, 0.1) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
color: var(--cyan) !important;
background: var(--surface) !important;
border-color: var(--success) !important;
color: var(--success) !important;
}
.el-message--error {
background: rgba(255, 107, 107, 0.1) !important;
border-color: rgba(255, 107, 107, 0.3) !important;
background: var(--surface) !important;
border-color: var(--danger) !important;
color: var(--danger) !important;
}
.el-message--warning {
background: rgba(255, 184, 0, 0.1) !important;
border-color: rgba(255, 184, 0, 0.3) !important;
color: var(--yellow) !important;
background: var(--surface) !important;
border-color: var(--warning) !important;
color: var(--warning) !important;
}
.el-message--info {
background: rgba(255, 107, 157, 0.1) !important;
border-color: rgba(255, 107, 157, 0.3) !important;
background: var(--surface) !important;
border-color: var(--primary) !important;
color: var(--primary) !important;
}
/* -------------------- 消息盒子 -------------------- */
.el-message-box {
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-md) !important;
box-shadow: var(--shadow-xl) !important;
background: var(--surface) !important;
border-radius: var(--radius-lg) !important;
}
.el-message-box__header {
@@ -334,33 +476,118 @@
}
.el-message-box__title {
color: var(--primary) !important;
color: var(--text-primary) !important;
font-weight: 600;
}
.el-message-box__content {
color: var(--text-primary) !important;
color: var(--text-secondary) !important;
}
.el-message-box__btns {
border-top: 1px solid var(--border) !important;
}
/* -------------------- 警告提示 -------------------- */
.el-alert {
border-radius: var(--radius-md) !important;
}
.el-alert--success {
background-color: rgba(0, 255, 136, 0.1) !important;
border-color: var(--green) !important;
background-color: var(--success-soft) !important;
border: 1px solid rgba(34, 197, 94, 0.3) !important;
color: var(--success) !important;
}
.el-alert--info {
background-color: rgba(255, 107, 157, 0.1) !important;
border-color: var(--primary) !important;
background-color: var(--primary-soft) !important;
border: 1px solid rgba(146, 124, 255, 0.3) !important;
color: var(--primary) !important;
}
.el-alert--warning {
background-color: rgba(255, 184, 0, 0.1) !important;
border-color: var(--yellow) !important;
background-color: var(--warning-soft) !important;
border: 1px solid rgba(245, 158, 11, 0.3) !important;
color: var(--warning) !important;
}
.el-alert--error {
background-color: rgba(255, 51, 102, 0.1) !important;
border-color: var(--danger) !important;
background-color: var(--danger-soft) !important;
border: 1px solid rgba(251, 113, 133, 0.3) !important;
color: var(--danger) !important;
}
/* -------------------- Switch 开关 -------------------- */
.theme-switch.el-switch .el-switch__core {
background: var(--surface-3);
border-color: var(--border);
}
.theme-switch.el-switch.is-checked .el-switch__core {
border-color: var(--primary) !important;
background-color: var(--primary) !important;
}
/* -------------------- 进度条 -------------------- */
.el-progress-bar__outer {
background-color: var(--surface-3) !important;
}
.el-progress-bar__inner {
background: var(--gradient-primary) !important;
}
.el-progress__text {
color: var(--text-secondary) !important;
}
/* -------------------- 菜单 -------------------- */
.el-menu {
background: transparent !important;
border-right: none !important;
}
.el-menu-item {
color: var(--text-secondary) !important;
border-radius: var(--radius-md);
margin: 4px 8px;
}
.el-menu-item:hover {
background: var(--surface-2) !important;
color: var(--primary) !important;
}
.el-menu-item.is-active {
background: var(--primary-soft) !important;
color: var(--primary) !important;
font-weight: 600;
}
/* -------------------- Tabs -------------------- */
.el-tabs__nav-wrap::after {
background-color: var(--border) !important;
}
.el-tabs__item {
color: var(--text-muted) !important;
}
.el-tabs__item:hover {
color: var(--primary) !important;
}
.el-tabs__item.is-active {
color: var(--primary) !important;
}
.el-tabs__active-bar {
background-color: var(--primary) !important;
}
/* -------------------- Tooltip -------------------- */
.el-tooltip__popper {
background: var(--surface-2) !important;
border: 1px solid var(--border) !important;
color: var(--text-primary) !important;
}

View File

@@ -1,15 +1,19 @@
/**
* 工具类 CSS - 冷灰紫主题
* 提供通用的布局和样式工具类
*/
/* ==================== 卡片 ==================== */
.card-base {
border-radius: var(--radius-xl);
background: var(--bg-card);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
transition: var(--transition-hover);
}
.card-base:hover {
box-shadow: var(--shadow-md);
border-color: var(--border-light);
transform: translateY(-2px);
border-color: var(--primary);
}
.card-header {
@@ -22,14 +26,15 @@
.card-title {
font-size: 18px;
font-weight: 700;
color: var(--primary);
letter-spacing: 1px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
}
/* ==================== 按钮工具类 ==================== */
.btn-base {
border-radius: var(--radius-md);
font-weight: 600;
font-weight: 500;
transition: var(--transition-base);
border: 1px solid var(--border);
display: inline-flex;
@@ -38,61 +43,87 @@
gap: 8px;
cursor: pointer;
outline: none;
padding: 8px 16px;
background: var(--surface-2);
color: var(--text-secondary);
}
.btn-primary {
background: var(--gradient-primary);
.btn-base:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--surface-3);
}
/* 主要按钮 - 深紫实心 */
.btn-primary {
background: var(--primary-solid);
border-color: var(--primary-solid);
color: white;
}
.btn-primary:hover {
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3);
transform: translateY(-2px);
background: var(--primary-solid-hover);
border-color: var(--primary-solid-hover);
box-shadow: var(--shadow-primary-md);
transform: translateY(-1px);
color: white;
}
/* 成功按钮 */
.btn-success {
background: var(--gradient-cyan);
border-color: var(--cyan);
color: white;
background: var(--success);
border-color: var(--success);
color: var(--bg);
}
.btn-success:hover {
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
transform: translateY(-2px);
background: #2DD4BF;
border-color: #2DD4BF;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
transform: translateY(-1px);
color: var(--bg);
}
/* 警告按钮 */
.btn-warning {
background: var(--gradient-yellow);
border-color: var(--yellow);
color: white;
background: var(--warning);
border-color: var(--warning);
color: var(--bg);
}
.btn-warning:hover {
box-shadow: 0 4px 12px rgba(255, 184, 0, 0.3);
transform: translateY(-2px);
background: #FBBF24;
border-color: #FBBF24;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
transform: translateY(-1px);
color: var(--bg);
}
/* 危险按钮 */
.btn-danger {
background: var(--gradient-danger);
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover {
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
transform: translateY(-2px);
background: #FCA5A5;
border-color: #FCA5A5;
box-shadow: 0 0 20px rgba(251, 113, 133, 0.3);
transform: translateY(-1px);
color: white;
}
.btn-icon {
font-size: 20px;
font-size: 18px;
margin-right: 0;
vertical-align: middle;
}
/* ==================== 布局 ==================== */
.page-container {
padding: 20px;
background: var(--bg-page);
padding: 24px;
background: var(--bg);
min-height: 100vh;
}
@@ -104,7 +135,7 @@
.form-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
align-items: flex-end;
}
@@ -114,12 +145,19 @@
gap: 20px;
}
@media (max-width: 768px) {
@media (max-width: 1200px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stat-grid {
grid-template-columns: 1fr;
}
}
/* Flex 工具类 */
.flex-center {
display: flex;
align-items: center;
@@ -137,50 +175,103 @@
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-1 {
flex: 1;
}
/* ==================== 文字颜色 ==================== */
.text-primary {
color: var(--primary);
}
.text-cyan {
color: var(--cyan);
.text-accent {
color: var(--accent);
}
.text-success {
color: var(--green);
color: var(--success);
}
.text-warning {
color: var(--yellow);
color: var(--warning);
}
.text-danger {
color: var(--danger);
}
.text-info {
color: var(--info);
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
/* ==================== 背景 ==================== */
.bg-gradient-primary {
background: var(--gradient-primary);
}
.bg-gradient-cyan {
background: var(--gradient-cyan);
.bg-gradient-accent {
background: var(--gradient-accent);
}
.border-pink {
.bg-surface {
background: var(--surface);
}
.bg-surface-2 {
background: var(--surface-2);
}
.bg-surface-3 {
background: var(--surface-3);
}
/* ==================== 边框 ==================== */
.border-default {
border: 1px solid var(--border);
}
.rounded-xl {
border-radius: var(--radius-xl);
.border-light {
border: 1px solid var(--border-light);
}
.border-primary {
border: 1px solid var(--primary);
}
.border-none {
border: none;
}
/* ==================== 圆角 ==================== */
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
/* ==================== 阴影 ==================== */
.shadow-sm {
box-shadow: var(--shadow-sm);
}
@@ -188,3 +279,156 @@
.shadow-md {
box-shadow: var(--shadow-md);
}
.shadow-lg {
box-shadow: var(--shadow-lg);
}
.shadow-xl {
box-shadow: var(--shadow-xl);
}
.shadow-none {
box-shadow: none;
}
.shadow-primary {
box-shadow: var(--shadow-primary-md);
}
/* ==================== 间距 ==================== */
.gap-4 {
gap: 4px;
}
.gap-8 {
gap: 8px;
}
.gap-12 {
gap: 12px;
}
.gap-16 {
gap: 16px;
}
.gap-20 {
gap: 20px;
}
/* ==================== 文本工具 ==================== */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-nowrap {
white-space: nowrap;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
/* ==================== 显示 ==================== */
.hidden {
display: none;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
/* ==================== 响应式隐藏 ==================== */
@media (max-width: 768px) {
.hidden-mobile {
display: none !important;
}
}
@media (min-width: 769px) {
.hidden-desktop {
display: none !important;
}
}
/* ==================== 焦点样式 ==================== */
.focus-primary:focus {
outline: none;
box-shadow: var(--shadow-primary-sm);
border-color: var(--primary);
}
.focus-accent:focus {
outline: none;
box-shadow: var(--shadow-accent-sm);
border-color: var(--accent);
}
/* ==================== 状态指示器 ==================== */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot--success {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot--warning {
background: var(--warning);
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.status-dot--danger {
background: var(--danger);
box-shadow: 0 0 8px rgba(251, 113, 133, 0.5);
}
.status-dot--info {
background: var(--info);
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
}
/* ==================== 分隔线 ==================== */
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.divider-vertical {
width: 1px;
background: var(--border);
margin: 0 16px;
align-self: stretch;
}

View File

@@ -1,43 +1,87 @@
/**
* CSS 变量定义 - 冷灰紫主题
* 设计理念:冷灰做信息底座,克制紫色做品牌识别和交互强调
* 参考Material 3 颜色角色体系
*/
:root {
--primary: #FF6B9D;
--primary-light: #FF8FB3;
--primary-dark: #FF5A8F;
/* ==================== 背景层次 (Surface Roles) ==================== */
--bg: #0F1117; /* 最底层背景,接近黑但不是纯黑 */
--surface: #181C25; /* 卡片、表格、侧边栏 */
--surface-2: #1F2430; /* 悬停状态、次级面板 */
--surface-3: #262C3A; /* 输入框、选中行背景 */
--border: #2E3545; /* 边框,负责把结构切出来 */
--border-light: #3A4356; /* 稍亮的边框 */
--cyan: #00D4FF;
--cyan-light: #00E5FF;
--cyan-dark: #00B8E0;
/* ==================== 文字颜色 ==================== */
--text-primary: #F5F7FA; /* 主要文字,对比度充足 */
--text-secondary: #A5AEBD; /* 次要文字 */
--text-muted: #7C8596; /* 弱化文字、placeholder */
--green: #34D399;
--yellow: #FFB800;
--danger: #FF6B6B;
--purple: #A855F7;
/* ==================== 品牌紫色系 (Brand Purple) ==================== */
/* 亮紫:用于链接、选中、图标、焦点 - "发光"和识别 */
--primary: #927CFF;
--primary-rgb: 146, 124, 255;
--primary-hover: #A78BFA;
--primary-soft: #2A2442; /* 淡紫背景,用于选中态底色 */
--bg-page: #FAFAFA;
--bg-card: #FFFFFF;
--bg-light: #FFF9FB;
/* 深紫:用于实心按钮,配白字更稳 - "承载文字" */
--primary-solid: #6B4EFF;
--primary-solid-hover: #5B3DF5;
--text-primary: #333333;
--text-secondary: #999999;
--text-muted: #666666;
/* ==================== 辅助色 (Accent) ==================== */
--accent: #2DD4BF; /* 青绿色,辅助强调 */
--accent-rgb: 45, 212, 191;
--accent-soft: #163A39; /* 淡青背景 */
--border: #FFE4EC;
--border-light: #FFD6E3;
/* ==================== 语义状态色 (Semantic Colors) ==================== */
/* 紫色只表示"交互和品牌";绿/橙/红只表示"系统状态" */
--success: #22C55E;
--success-rgb: 34, 197, 94;
--success-soft: #1A3A28;
--gradient-primary: linear-gradient(135deg, #FF6B9D 0%, #FF8FB3 100%);
--gradient-cyan: linear-gradient(135deg, #00D4FF 0%, #00E5FF 100%);
--gradient-yellow: linear-gradient(135deg, #FFB800 0%, #FFD000 100%);
--gradient-danger: linear-gradient(135deg, #FF6B6B 0%, #FF8B8B 100%);
--warning: #F59E0B;
--warning-rgb: 245, 158, 11;
--warning-soft: #3D3118;
--danger: #FB7185;
--danger-rgb: 251, 113, 133;
--danger-soft: #3D1F26;
--info: #38BDF8;
--info-rgb: 56, 189, 248;
--info-soft: #1A3A4A;
/* ==================== 渐变 ==================== */
--gradient-primary: linear-gradient(135deg, #6B4EFF 0%, #927CFF 100%);
--gradient-accent: linear-gradient(135deg, #2DD4BF 0%, #38BDF8 100%);
--gradient-surface: linear-gradient(180deg, #181C25 0%, #1F2430 100%);
/* ==================== 圆角 ==================== */
--radius-sm: 6px;
--radius-md: 8px;
--radius-md: 10px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-xl: 14px;
--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-lg: 0 8px 20px rgba(255, 107, 157, 0.2);
/* ==================== 阴影 (克制使用,更多靠层级和边框) ==================== */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
--transition-base: all 0.3s ease;
--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;
}

View File

@@ -0,0 +1,63 @@
import { ElMessage } from 'element-plus'
/**
* 复制文本到剪贴板
* @param {string} text
* @param {string} [successMsg]
* @param {string} [errorMsg]
* @returns {Promise<boolean>}
*/
export async function copyToClipboard(text, successMsg, errorMsg = '复制失败呢~') {
if (!text) {
ElMessage.warning('没有可复制的内容')
return false
}
try {
await navigator.clipboard.writeText(text)
ElMessage.success(successMsg || `已复制 ${text} 到剪贴板啦~`)
return true
} catch (error) {
console.error('复制失败:', error)
ElMessage.error(errorMsg)
return false
}
}
/**
* 复制代理地址
* @param {object} proxy - { ip, port }
* @returns {Promise<boolean>}
*/
export async function copyProxy(proxy) {
if (!proxy?.ip || !proxy?.port) {
ElMessage.warning('代理信息不完整')
return false
}
const text = `${proxy.ip}:${proxy.port}`
return copyToClipboard(text)
}
/**
* 复制文本(备选方案:使用 DOM
* @param {string} text
* @returns {boolean}
*/
export function copyTextFallback(text) {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0;'
document.body.appendChild(textarea)
try {
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand('copy')
document.body.removeChild(textarea)
return success
} catch {
document.body.removeChild(textarea)
return false
}
}

View File

@@ -0,0 +1,84 @@
import { ElMessageBox } from 'element-plus'
/**
* 确认对话框工具
*/
/** 默认配置 */
const DEFAULT_CONFIG = {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
/**
* 显示确认对话框
* @param {string} message
* @param {string} [title]
* @param {object} [config]
* @returns {Promise<boolean>} 用户点击确定返回 true取消返回 false
*/
export async function confirm(message, title = '提示', config = {}) {
try {
await ElMessageBox.confirm(message, title, {
...DEFAULT_CONFIG,
...config
})
return true
} catch {
return false
}
}
/**
* 显示删除确认对话框
* @param {string} message
* @param {string} [itemName] - 要删除的项目名称
* @returns {Promise<boolean>}
*/
export async function confirmDelete(message, itemName = '') {
const fullMessage = itemName
? `确定要删除${itemName}吗?此操作不可恢复。`
: message
return confirm(fullMessage, '删除确认', {
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
})
}
/**
* 显示批量删除确认对话框
* @param {number} count
* @param {string} [itemName]
* @returns {Promise<boolean>}
*/
export async function confirmBatchDelete(count, itemName = '项') {
return confirm(
`确定要删除选中的 ${count}${itemName}吗?`,
'批量删除确认',
{
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
}
)
}
/**
* 显示清空确认对话框
* @param {string} [target]
* @returns {Promise<boolean>}
*/
export async function confirmClear(target = '所有数据') {
return confirm(
`确定要清空${target}吗?此操作不可恢复。`,
'清空确认',
{
confirmButtonText: '清空吧~',
cancelButtonText: '再等等',
type: 'danger'
}
)
}

View File

@@ -0,0 +1,108 @@
/**
* 格式化工具函数
*/
/**
* 格式化日期时间
* @param {string|Date|number} dateTimeStr
* @param {string} [fallback] - 无效日期时的回退文本
* @returns {string}
*/
export function formatDateTime(dateTimeStr, fallback = '-') {
if (!dateTimeStr) return fallback
const date = new Date(dateTimeStr)
if (isNaN(date.getTime())) return fallback
const pad = (n) => String(n).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hours = pad(date.getHours())
const minutes = pad(date.getMinutes())
const seconds = pad(date.getSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
/**
* 格式化时间(简化版)
* @param {string|Date|number} timeStr
* @param {string} [fallback]
* @returns {string}
*/
export function formatTime(timeStr, fallback = '-') {
if (!timeStr) return fallback
const date = new Date(timeStr)
if (isNaN(date.getTime())) return fallback
return date.toLocaleString('zh-CN')
}
/**
* 格式化数字(添加千分位)
* @param {number} num
* @param {number} [decimals] - 小数位数
* @returns {string}
*/
export function formatNumber(num, decimals = 0) {
if (typeof num !== 'number' || isNaN(num)) return '-'
return num.toLocaleString('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
})
}
/**
* 格式化百分比
* @param {number} value
* @param {number} [total]
* @param {number} [decimals]
* @returns {string}
*/
export function formatPercent(value, total, decimals = 1) {
if (total !== undefined) {
if (!total) return '0%'
value = (value / total) * 100
}
return `${value.toFixed(decimals)}%`
}
/**
* 格式化文件大小
* @param {number} bytes
* @param {number} [decimals]
* @returns {string}
*/
export function formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`
}
/**
* 格式化时长(秒转可读文本)
* @param {number} seconds
* @returns {string}
*/
export function formatDuration(seconds) {
if (!seconds || seconds < 0) return '-'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}小时${minutes}${secs}`
} else if (minutes > 0) {
return `${minutes}${secs}`
} else {
return `${secs}`
}
}

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,37 @@
<template>
<div class="page-container">
<PageHeader title="代理列表" icon="📋" />
<PageHeader title="代理列表" :icon="Document" />
<el-card class="filter-card" shadow="hover">
<el-form :inline="true" :model="filterForm" class="form-row">
<el-form-item label="协议类型">
<el-select v-model="filterForm.protocol" placeholder="全部" clearable style="width: 120px" @change="handleSearch">
<el-option label="全部" value=""></el-option>
<el-option label="HTTP" value="http"></el-option>
<el-option label="HTTPS" value="https"></el-option>
<el-option label="SOCKS4" value="socks4"></el-option>
<el-option label="SOCKS5" value="socks5"></el-option>
<el-select
v-model="filterForm.protocol"
placeholder="全部"
clearable
style="width: 120px"
@change="handleSearch"
>
<el-option label="全部" value="" />
<el-option label="HTTP" value="http" />
<el-option label="HTTPS" value="https" />
<el-option label="SOCKS4" value="socks4" />
<el-option label="SOCKS5" value="socks5" />
</el-select>
</el-form-item>
<el-form-item label="最低分数">
<el-input-number v-model="filterForm.minScore" :min="0" :max="10" style="width: 120px" @change="handleSearch" />
<el-input-number
v-model="filterForm.minScore"
:min="0"
:max="10"
style="width: 120px"
@change="handleSearch"
/>
</el-form-item>
<el-form-item label="排序方式">
<el-select v-model="filterForm.sortBy" style="width: 140px" @change="handleSearch">
<el-option label="更新时间" value="last_check"></el-option>
<el-option label="分数" value="score"></el-option>
<el-option label="更新时间" value="last_check" />
<el-option label="分数" value="score" />
</el-select>
</el-form-item>
</el-form>
@@ -28,16 +40,25 @@
<el-card class="table-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">代理详情</span>
<span class="card-title">
<el-icon class="header-icon"><List /></el-icon>
代理详情
</span>
<div class="header-actions">
<el-button-group>
<el-button type="danger" @click="handleBatchDelete" :disabled="selectedProxies.length === 0">
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="selectedProxies.length === 0"
>
<el-icon class="btn-icon"><Delete /></el-icon>
批量删除
</el-button>
<el-dropdown trigger="click" @command="handleExport">
<el-button type="success">
<el-icon class="btn-icon"><Download /></el-icon>
导出
<el-icon class="el-icon--right"><component :is="ArrowDownIcon" /></el-icon>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
@@ -58,41 +79,46 @@
v-loading="proxyStore.loading"
@selection-change="handleSelectionChange"
:row-style="{ cursor: 'pointer' }"
empty-text="暂无数据"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="ip" label="IP地址" width="150" />
<el-table-column prop="port" label="端口" width="100" />
<el-table-column prop="protocol" label="协议" width="100">
<template #default="scope">
<el-tag :type="getProtocolType(scope.row.protocol)" effect="light">
{{ scope.row.protocol.toUpperCase() }}
<template #default="{ row }">
<el-tag :type="getProtocolType(row.protocol)" effect="light" size="small">
{{ row.protocol.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" label="分数" width="100">
<template #default="scope">
<span class="score-value">{{ scope.row.score || 0 }}</span>
<template #default="{ row }">
<span class="score-value" :class="{ 'score-high': row.score >= 8, 'score-medium': row.score >= 5 && row.score < 8, 'score-low': row.score < 5 }">
{{ row.score || 0 }}
</span>
</template>
</el-table-column>
<el-table-column prop="last_check" label="最后检查时间">
<template #default="scope">
{{ formatDateTime(scope.row.last_check) }}
<el-table-column prop="last_check" label="最后检查时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.last_check) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click.stop="handleCopy(scope.row)"
@click.stop="handleCopy(row)"
>
<el-icon class="btn-icon"><CopyDocument /></el-icon>
复制
</el-button>
<el-button
type="danger"
size="small"
@click.stop="handleDelete(scope.row)"
@click.stop="handleDelete(row)"
>
<el-icon class="btn-icon"><Delete /></el-icon>
删除
</el-button>
</template>
@@ -103,7 +129,7 @@
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:page-sizes="PAGE_SIZE_OPTIONS"
:total="proxyStore.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@@ -115,19 +141,27 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, ArrowDown, List, Delete, Download, CopyDocument } from '@element-plus/icons-vue'
import { useProxyStore } from '../stores/proxy'
import { formatDateTime } from '../utils/format'
import { confirmDelete, confirmBatchDelete } from '../utils/confirm'
import { copyProxy } from '../utils/clipboard'
import PageHeader from '../components/PageHeader.vue'
const ArrowDownIcon = ArrowDown
/** 分页选项 */
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
/** 默认分页大小 */
const DEFAULT_PAGE_SIZE = 20
const proxyStore = useProxyStore()
// ==================== 状态 ====================
const currentPage = ref(1)
const pageSize = ref(20)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const selectedProxies = ref([])
let abortController = null
const filterForm = reactive({
protocol: '',
@@ -136,29 +170,26 @@ const filterForm = reactive({
sortOrder: 'DESC'
})
function getProtocolType(protocol) {
const types = {
http: 'primary',
// ==================== 协议类型映射 ====================
const PROTOCOL_TYPE_MAP = {
http: 'info',
https: 'success',
socks4: 'warning',
socks5: 'danger'
}
return types[protocol] || 'info'
socks5: 'primary'
}
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}`
function getProtocolType(protocol) {
return PROTOCOL_TYPE_MAP[protocol] || 'info'
}
// ==================== 数据获取 ====================
async function fetchProxies() {
// 取消之前的请求
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
await proxyStore.fetchProxies({
page: currentPage.value,
page_size: pageSize.value,
@@ -166,75 +197,55 @@ async function fetchProxies() {
min_score: filterForm.minScore,
sort_by: filterForm.sortBy,
sort_order: filterForm.sortOrder
})
}, abortController.signal)
abortController = null
}
// ==================== 事件处理 ====================
function handleSearch() {
currentPage.value = 1
fetchProxies()
}
function handleReset() {
filterForm.protocol = ''
filterForm.minScore = 0
filterForm.sortBy = 'last_check'
currentPage.value = 1
fetchProxies()
}
function handleSelectionChange(selection) {
selectedProxies.value = selection.map(item => [item.ip, item.port])
}
async function handleCopy(proxy) {
const text = `${proxy.ip}:${proxy.port}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success(`已复制 ${text} 到剪贴板啦~`)
} catch {
ElMessage.error('复制失败呢~')
}
await copyProxy(proxy)
}
async function handleDelete(proxy) {
try {
await ElMessageBox.confirm(`确定要删除代理 ${proxy.ip}:${proxy.port} 吗?`, '提示', {
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
})
const confirmed = await confirmDelete(`代理 ${proxy.ip}:${proxy.port}`)
if (!confirmed) return
const success = await proxyStore.deleteProxy(proxy.ip, proxy.port)
if (success) {
ElMessage.success('删除成功啦~')
ElMessage.success('删除成功')
fetchProxies()
}
} catch {
}
}
async function handleBatchDelete() {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedProxies.value.length} 个代理吗?`, '提示', {
confirmButtonText: '删除吧~',
cancelButtonText: '再等等',
type: 'warning'
})
const count = selectedProxies.value.length
if (!count) return
const confirmed = await confirmBatchDelete(count, '代理')
if (!confirmed) return
const deletedCount = await proxyStore.batchDeleteProxies(selectedProxies.value)
if (deletedCount > 0) {
ElMessage.success(`批量删除成功啦~共删除了 ${deletedCount} 个代理`)
ElMessage.success(`已删除 ${deletedCount} 个代理`)
selectedProxies.value = []
fetchProxies()
}
} catch {
}
}
async function handleExport(format) {
const success = await proxyStore.exportProxies(format, filterForm.protocol || null)
if (success) {
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功啦~`)
ElMessage.success(`导出 ${format.toUpperCase()} 格式成功`)
}
}
@@ -249,29 +260,40 @@ function handleCurrentChange(page) {
fetchProxies()
}
// ==================== 生命周期 ====================
onMounted(() => {
fetchProxies()
})
onUnmounted(() => {
if (abortController) {
abortController.abort()
abortController = null
}
})
</script>
<style scoped>
.filter-card {
margin-bottom: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.table-card {
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.table-card:hover,
.filter-card:hover {
border-color: var(--border-light);
}
.card-title {
font-size: 18px;
font-weight: 600;
.header-icon {
margin-right: 8px;
color: var(--primary);
}
@@ -280,15 +302,28 @@ onMounted(() => {
gap: 10px;
}
.btn-icon {
font-size: 20px;
margin-right: 0;
.score-value {
font-weight: 600;
font-size: 14px;
}
.score-high {
color: var(--success);
}
.score-medium {
color: var(--warning);
}
.score-low {
color: var(--danger);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
padding: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
</style>

View File

@@ -1,35 +1,93 @@
<template>
<div class="page-container">
<PageHeader title="系统设置" icon="⚙️" />
<PageHeader title="系统设置" :icon="Setting" />
<!-- 验证调度器控制 -->
<el-card class="settings-card scheduler-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">
<el-icon class="header-icon"><Timer /></el-icon>
验证调度器
</span>
<div class="scheduler-status">
<span class="status-dot" :class="{ active: schedulerRunning }"></span>
<span class="status-text">{{ schedulerRunning ? '运行中' : '已停止' }}</span>
</div>
</div>
</template>
<div class="scheduler-actions">
<el-button
type="success"
@click="handleStartScheduler"
:disabled="schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPlay /></el-icon>
启动自动验证
</el-button>
<el-button
type="danger"
@click="handleStopScheduler"
:disabled="!schedulerRunning"
:loading="schedulerLoading"
>
<el-icon class="btn-icon"><VideoPause /></el-icon>
停止自动验证
</el-button>
<el-button
type="primary"
@click="handleValidateNow"
:loading="validating"
>
<el-icon class="btn-icon"><Refresh /></el-icon>
立即验证全部
</el-button>
</div>
<div class="scheduler-info">
<el-alert
:title="schedulerInfo"
type="info"
:closable="false"
show-icon
/>
</div>
</el-card>
<!-- 基础配置 -->
<el-card class="settings-card" shadow="hover" v-loading="loading">
<template #header>
<div class="card-header">
<span class="card-title">🎨 基础配置</span>
<el-button type="primary" @click="handleSave" size="large" :loading="saving">
<span class="btn-icon">💾</span>
<span class="card-title">
<el-icon class="header-icon"><Tools /></el-icon>
基础配置
</span>
<el-button
type="primary"
@click="handleSave"
size="large"
:loading="saving"
>
<el-icon class="btn-icon"><DocumentChecked /></el-icon>
保存配置
</el-button>
</div>
</template>
<el-form :model="settings" label-width="150px" class="settings-form">
<el-form-item label="管理员API Key">
<el-input
v-model="settings.api_key"
placeholder="请输入管理员API Key"
type="password"
show-password
class="setting-input"
/>
<div class="setting-hint">用于执行管理操作的API Key</div>
</el-form-item>
<el-form
:model="settings"
label-width="180px"
class="settings-form"
:rules="formRules"
ref="formRef"
>
<el-divider content-position="left">爬虫配置</el-divider>
<el-form-item label="数据库路径">
<el-input v-model="settings.db_path" placeholder="数据库文件路径" />
</el-form-item>
<el-form-item label="爬取超时">
<el-form-item label="爬取超时" prop="crawl_timeout">
<el-input-number
v-model="settings.crawl_timeout"
:min="5"
@@ -40,7 +98,18 @@
<span class="setting-suffix"></span>
</el-form-item>
<el-form-item label="验证超时">
<el-form-item label="最大重试次数" prop="max_retries">
<el-input-number
v-model="settings.max_retries"
:min="0"
:max="10"
class="setting-input"
/>
</el-form-item>
<el-divider content-position="left">验证配置</el-divider>
<el-form-item label="验证超时" prop="validation_timeout">
<el-input-number
v-model="settings.validation_timeout"
:min="3"
@@ -51,16 +120,7 @@
<span class="setting-suffix"></span>
</el-form-item>
<el-form-item label="最大重试次数">
<el-input-number
v-model="settings.max_retries"
:min="0"
:max="10"
class="setting-input"
/>
</el-form-item>
<el-form-item label="默认并发数">
<el-form-item label="验证并发数" prop="default_concurrency">
<el-input-number
v-model="settings.default_concurrency"
:min="10"
@@ -70,17 +130,39 @@
/>
</el-form-item>
<el-form-item label="最低代理分数">
<el-form-item label="自动验证间隔" prop="validate_interval_minutes">
<el-input-number
v-model="settings.min_proxy_score"
:min="0"
:max="10"
:step="1"
v-model="settings.validate_interval_minutes"
:min="5"
:max="1440"
:step="5"
class="setting-input"
/>
<span class="setting-suffix">分钟</span>
</el-form-item>
<el-form-item label="启用自动验证" prop="auto_validate">
<el-switch
v-model="settings.auto_validate"
active-text="开启"
inactive-text="关闭"
/>
</el-form-item>
<el-form-item label="代理过期时间">
<el-divider content-position="left">代理评分配置</el-divider>
<el-form-item label="最低代理分数" prop="min_proxy_score">
<el-input-number
v-model="settings.min_proxy_score"
:min="0"
:max="100"
:step="1"
class="setting-input"
/>
<span class="setting-hint">分数低于此值的代理将被隐藏</span>
</el-form-item>
<el-form-item label="代理过期时间" prop="proxy_expiry_days">
<el-input-number
v-model="settings.proxy_expiry_days"
:min="1"
@@ -89,6 +171,7 @@
class="setting-input"
/>
<span class="setting-suffix"></span>
<span class="setting-hint">超过此时间未验证的代理将被清理</span>
</el-form-item>
</el-form>
</el-card>
@@ -96,73 +179,207 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Setting,
DocumentChecked,
Tools,
Timer,
VideoPlay,
VideoPause,
Refresh
} from '@element-plus/icons-vue'
import { settingsAPI, schedulerAPI } from '../api'
import PageHeader from '../components/PageHeader.vue'
// ==================== 状态 ====================
const loading = ref(false)
const saving = ref(false)
const formRef = ref(null)
const schedulerRunning = ref(false)
const schedulerLoading = ref(false)
const validating = ref(false)
const settings = reactive({
api_key: '',
db_path: '',
crawl_timeout: 30,
validation_timeout: 10,
max_retries: 3,
default_concurrency: 50,
min_proxy_score: 5,
proxy_expiry_days: 7
min_proxy_score: 0,
proxy_expiry_days: 7,
auto_validate: true,
validate_interval_minutes: 30
})
// ==================== 计算属性 ====================
const schedulerInfo = computed(() => {
if (schedulerRunning.value) {
return `验证调度器正在运行,每 ${settings.validate_interval_minutes} 分钟自动验证一次所有代理`
} else {
return '验证调度器已停止,代理不会自动验证,建议定期手动验证或开启自动验证'
}
})
// ==================== 表单验证规则 ====================
const formRules = {
crawl_timeout: [{ type: 'number', min: 5, max: 120, message: '范围 5-120 秒', trigger: 'blur' }],
validation_timeout: [{ type: 'number', min: 3, max: 60, message: '范围 3-60 秒', trigger: 'blur' }],
max_retries: [{ type: 'number', min: 0, max: 10, message: '范围 0-10', trigger: 'blur' }],
default_concurrency: [{ type: 'number', min: 10, max: 200, message: '范围 10-200', trigger: 'blur' }],
validate_interval_minutes: [{ type: 'number', min: 5, max: 1440, message: '范围 5-1440 分钟', trigger: 'blur' }],
min_proxy_score: [{ type: 'number', min: 0, max: 100, message: '范围 0-100', trigger: 'blur' }],
proxy_expiry_days: [{ type: 'number', min: 1, max: 30, message: '范围 1-30 天', trigger: 'blur' }]
}
// ==================== 数据获取 ====================
async function fetchSettings() {
loading.value = true
try {
const response = await fetch('http://localhost:8923/api/settings')
if (response.ok) {
const data = await response.json()
Object.assign(settings, data)
const response = await settingsAPI.getSettings()
if (response.code === 200) {
Object.assign(settings, response.data)
}
settings.api_key = localStorage.getItem('api_key') || ''
} catch (error) {
console.error('获取设置失败:', error)
ElMessage.error('获取设置失败')
} finally {
loading.value = false
}
}
async function fetchSchedulerStatus() {
try {
const response = await schedulerAPI.getStatus()
if (response.code === 200) {
schedulerRunning.value = response.data.running
}
} catch (error) {
console.error('获取调度器状态失败:', error)
}
}
// ==================== 调度器控制 ====================
async function handleStartScheduler() {
schedulerLoading.value = true
try {
const response = await schedulerAPI.start()
if (response.code === 200) {
schedulerRunning.value = true
ElMessage.success('自动验证已启动')
} else {
ElMessage.error('启动失败')
}
} catch (error) {
console.error('启动调度器失败:', error)
ElMessage.error('启动失败')
} finally {
schedulerLoading.value = false
}
}
async function handleStopScheduler() {
schedulerLoading.value = true
try {
const response = await schedulerAPI.stop()
if (response.code === 200) {
schedulerRunning.value = false
ElMessage.success('自动验证已停止')
} else {
ElMessage.error('停止失败')
}
} catch (error) {
console.error('停止调度器失败:', error)
ElMessage.error('停止失败')
} finally {
schedulerLoading.value = false
}
}
async function handleValidateNow() {
try {
await ElMessageBox.confirm(
'确定要立即验证所有代理吗?这可能需要一些时间。',
'确认验证',
{
confirmButtonText: '开始验证',
cancelButtonText: '取消',
type: 'info'
}
)
validating.value = true
const response = await schedulerAPI.validateNow()
if (response.code === 200) {
ElMessage.success('全量验证已启动,请在日志中查看进度')
} else {
ElMessage.error('启动验证失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('启动验证失败:', error)
ElMessage.error('启动验证失败')
}
} finally {
validating.value = false
}
}
// ==================== 保存 ====================
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (settings.api_key) {
localStorage.setItem('api_key', settings.api_key)
} else {
localStorage.removeItem('api_key')
}
const response = await settingsAPI.saveSettings(settings)
const { api_key, ...settingsToSend } = settings
const response = await fetch('http://localhost:8923/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settingsToSend)
})
if (response.ok) {
ElMessage.success('配置保存成功啦~')
if (response.code === 200) {
ElMessage.success('配置保存成功')
// 刷新调度器状态
await fetchSchedulerStatus()
} else {
ElMessage.error('配置保存失败呢~')
ElMessage.error('配置保存失败')
}
} catch (error) {
console.error('保存设置失败:', error)
ElMessage.error('配置保存失败')
} finally {
saving.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
fetchSettings()
fetchSchedulerStatus()
})
</script>
<style scoped>
.settings-card {
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
background: var(--surface);
border: 1px solid var(--border);
margin-bottom: 20px;
}
.settings-card:hover {
border-color: var(--border-light);
}
.scheduler-card {
margin-bottom: 20px;
}
.header-icon {
margin-right: 8px;
color: var(--primary);
}
.btn-icon {
margin-right: 4px;
}
.card-header {
@@ -172,13 +389,49 @@ onMounted(() => {
}
.card-title {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--primary);
display: flex;
align-items: center;
}
.scheduler-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
transition: background 0.3s;
}
.status-dot.active {
background: #67c23a;
box-shadow: 0 0 8px #67c23a;
}
.status-text {
font-size: 14px;
color: var(--text-secondary);
}
.scheduler-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.scheduler-info {
margin-top: 8px;
}
.settings-form {
padding: 20px;
padding: 16px;
max-width: 800px;
}
@@ -188,18 +441,29 @@ onMounted(() => {
.setting-suffix {
margin-left: 10px;
color: var(--text-secondary);
color: var(--text-muted);
font-size: 14px;
}
.setting-hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
margin-left: 10px;
color: var(--text-muted);
font-size: 13px;
}
.btn-icon {
font-size: 20px;
margin-right: 8px;
:deep(.el-form-item__label) {
color: var(--text-secondary);
font-weight: 500;
}
:deep(.el-divider__text) {
background: var(--surface);
color: var(--primary);
font-weight: 500;
}
:deep(.el-alert) {
background: var(--surface-2);
border: 1px solid var(--border);
}
</style>

View File

@@ -5,6 +5,30 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 6173
port: 9948,
// 支持 Vue Router 的 history 模式
historyApiFallback: true
},
preview: {
port: 9948,
historyApiFallback: true
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/echarts')) {
return 'echarts'
}
if (id.includes('node_modules/element-plus')) {
return 'element-plus'
}
if (id.includes('node_modules/vue') || id.includes('node_modules/vue-router') || id.includes('node_modules/pinia')) {
return 'vue-vendor'
}
}
}
},
chunkSizeWarningLimit: 600
}
})

View File

@@ -1,6 +1,6 @@
import sys
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.log import logger
@@ -29,6 +29,11 @@ class Fate0Plugin(BasePlugin):
port = data.get('port')
protocol = data.get('type', 'http')
# 协议标准化
protocol = protocol.lower().strip()
if protocol not in ('http', 'https', 'socks4', 'socks5'):
protocol = 'http'
if ip and port:
yield ip, int(port), protocol
count += 1

View File

@@ -12,7 +12,9 @@ class ProxyListDownloadPlugin(BasePlugin):
self.name = "ProxyListDownload"
self.urls = [
"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):
@@ -24,6 +26,16 @@ class ProxyListDownloadPlugin(BasePlugin):
lines = html.split('\n')
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:
line = line.strip()
if not line:
@@ -34,7 +46,6 @@ class ProxyListDownloadPlugin(BasePlugin):
if len(parts) >= 2:
ip = parts[0]
port = parts[1]
protocol = 'http' if 'type=http' in self.current_url else 'https'
yield ip, int(port), protocol
count += 1

View File

@@ -3,5 +3,6 @@ uvicorn[standard]==0.24.0
websockets==12.0
aiosqlite==0.19.0
aiohttp==3.9.1
aiohttp-socks==0.9.1
beautifulsoup4==4.12.3
lxml==5.1.0

View File

@@ -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)

View File

@@ -1,9 +1,95 @@
@echo off
chcp 65001 >nul
setlocal
cd /d %~dp0
echo === ProxyPool Startup ===
echo.
REM Launch via PowerShell to avoid encoding issues with Chinese characters
powershell -ExecutionPolicy Bypass -File start.ps1
set "ROOT_PATH=%~dp0.."
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

View File

@@ -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

View File

@@ -1,8 +1,41 @@
@echo off
chcp 65001 >nul
setlocal
cd /d %~dp0
echo === Stopping ProxyPool Services ===
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

View File

@@ -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 ""

View File

@@ -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("定时任务已停止")

View File

@@ -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
}
]
}