feat(dashboard): optimize dashboard layout and add new charts
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
# 本项目的运行参数已改为由 JSON 配置文件提供,不再使用环境变量。
|
|
||||||
#
|
|
||||||
# 后端:编辑项目根目录下的 config/app.json
|
|
||||||
# 前端 dev/build:编辑项目根目录下的 config/webui.json(与 WebUI 同级的 config 目录)
|
|
||||||
#
|
|
||||||
# 测试专用配置:config/app.test.json(pytest 会自动选用,勿与生产库共用 db_path)
|
|
||||||
480
DESIGN.md
480
DESIGN.md
@@ -1,480 +0,0 @@
|
|||||||
# ProxyPool 架构重构设计文档
|
|
||||||
|
|
||||||
> 目标:建立一个高度可扩展、分层清晰、易于维护的代理池系统。最关键的目标是**让添加新爬虫变得极其简单**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 架构总览
|
|
||||||
|
|
||||||
采用经典的分层架构:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Frontend (Vue3 + Vite + Element Plus) │
|
|
||||||
└─────────────┬───────────────────────────┘
|
|
||||||
│ HTTP/REST
|
|
||||||
┌─────────────▼───────────────────────────┐
|
|
||||||
│ API Layer (FastAPI Routers) │ ← 只负责:校验输入、调用 Service、格式化输出
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ Service Layer │ ← 业务逻辑编排:爬取策略、验证调度、导出逻辑
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ Plugin System (Crawlers) │ ← 爬虫插件:实现统一接口,返回原始代理数据
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ Task Queue & Workers │ ← 验证队列:背压控制、Worker 池、削峰填谷
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ Repository Layer │ ← 数据访问:所有 SQL 收敛于此
|
|
||||||
├─────────────────────────────────────────┤
|
|
||||||
│ Infrastructure (DB / Config / Log) │ ← 基础设施:连接池、配置、日志
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 后端核心设计原则
|
|
||||||
|
|
||||||
### 2.1 消灭全局单例,全面使用依赖注入 (DI)
|
|
||||||
当前 `scheduler = ValidationScheduler()` 是模块级全局变量,导致测试困难、隐式依赖。
|
|
||||||
|
|
||||||
重构后:
|
|
||||||
- 所有核心组件(DB、Scheduler、PluginManager)都通过 FastAPI `Depends` 注入
|
|
||||||
- 使用 `contextlib.asynccontextmanager` 在 lifespan 中初始化并挂载到 `app.state`
|
|
||||||
- 单元测试可以轻易 mock 任何一层
|
|
||||||
|
|
||||||
### 2.2 Repository 模式收敛所有 SQL
|
|
||||||
所有数据库操作从 `api_server.py`、`scheduler.py` 中彻底抽离到 `repositories/proxy_repo.py`。
|
|
||||||
|
|
||||||
好处:
|
|
||||||
- 换数据库时只改 Repository
|
|
||||||
- 写单元测试直接 mock Repository
|
|
||||||
- SQL 语句集中管理,防止散落在各处
|
|
||||||
|
|
||||||
### 2.3 任务队列解耦爬取与验证
|
|
||||||
当前插件爬取后直接 `asyncio.gather(*10000_tasks)` 验证,存在内存和并发风险。
|
|
||||||
|
|
||||||
重构后引入轻量级内存队列:
|
|
||||||
- `ValidationQueue`:基于 `asyncio.Queue`
|
|
||||||
- `ValidationWorkerPool`:固定数量的 Worker 从队列消费
|
|
||||||
- 爬取结果 `put` 进队列即返回,验证在后台进行
|
|
||||||
- 天然支持背压(backpressure),防止内存爆炸
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 插件系统设计(核心)
|
|
||||||
|
|
||||||
### 3.1 设计目标
|
|
||||||
**让添加一个新爬虫只需要做两件事:**
|
|
||||||
1. 创建一个类,继承 `BaseCrawlerPlugin`
|
|
||||||
2. 实现 `crawl()` 方法,返回 `list[ProxyRaw]`
|
|
||||||
|
|
||||||
### 3.2 插件接口
|
|
||||||
|
|
||||||
```python
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import List, AsyncIterator
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProxyRaw:
|
|
||||||
ip: str
|
|
||||||
port: int
|
|
||||||
protocol: str # http | https | socks4 | socks5
|
|
||||||
|
|
||||||
class BaseCrawlerPlugin:
|
|
||||||
"""所有爬虫插件必须继承的基类"""
|
|
||||||
|
|
||||||
name: str = "" # 插件唯一标识
|
|
||||||
display_name: str = "" # 展示名称
|
|
||||||
description: str = "" # 描述
|
|
||||||
enabled: bool = True # 是否默认启用
|
|
||||||
|
|
||||||
async def crawl(self) -> List[ProxyRaw]:
|
|
||||||
"""
|
|
||||||
爬取代理的核心方法。
|
|
||||||
可以是纯同步逻辑,也可以包含异步 HTTP 请求。
|
|
||||||
返回原始代理列表,不要在这里做验证。
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
async def health_check(self) -> bool:
|
|
||||||
"""可选:检查当前插件是否可用(如目标网站是否可访问)"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 插件注册机制
|
|
||||||
采用**显式注册 + 装饰器**模式,抛弃运行时目录扫描。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from core.plugin_system import registry
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
class MyNewPlugin(BaseCrawlerPlugin):
|
|
||||||
name = "my_new_plugin"
|
|
||||||
display_name = "我的新代理源"
|
|
||||||
|
|
||||||
async def crawl(self):
|
|
||||||
return [ProxyRaw("1.2.3.4", 8080, "http")]
|
|
||||||
```
|
|
||||||
|
|
||||||
优点:
|
|
||||||
- 类型安全:IDE 可以自动补全、静态检查
|
|
||||||
- 可控:不会出现意外加载未预期模块的问题
|
|
||||||
- 测试友好:测试时只注册 mock 插件
|
|
||||||
|
|
||||||
同时提供一个兼容入口 `registry.auto_discover("plugins")`,用于兼容现有习惯。
|
|
||||||
|
|
||||||
### 3.4 插件元数据持久化
|
|
||||||
插件的 `enabled` 状态应该持久化到数据库(或 settings JSON),而不是仅存在于内存。
|
|
||||||
|
|
||||||
新增 `plugin_settings` 表:
|
|
||||||
```sql
|
|
||||||
CREATE TABLE plugin_settings (
|
|
||||||
plugin_id TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER DEFAULT 1,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
启动时:
|
|
||||||
1. 加载所有已注册插件
|
|
||||||
2. 从 `plugin_settings` 读取持久化状态
|
|
||||||
3. 合并到插件实例中
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 任务调度与验证队列
|
|
||||||
|
|
||||||
### 4.1 验证队列设计
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ValidationQueue:
|
|
||||||
def __init__(self, worker_count: int = 50):
|
|
||||||
self.queue: asyncio.Queue[ProxyRaw] = asyncio.Queue()
|
|
||||||
self.worker_count = worker_count
|
|
||||||
self.workers: list[asyncio.Task] = []
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
self._running = True
|
|
||||||
for _ in range(self.worker_count):
|
|
||||||
self.workers.append(asyncio.create_task(self._worker_loop()))
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
self._running = False
|
|
||||||
for _ in self.workers:
|
|
||||||
self.queue.put_nowait(None) # sentinel
|
|
||||||
await asyncio.gather(*self.workers, return_exceptions=True)
|
|
||||||
|
|
||||||
async def submit(self, proxies: list[ProxyRaw]):
|
|
||||||
for p in proxies:
|
|
||||||
await self.queue.put(p)
|
|
||||||
|
|
||||||
async def _worker_loop(self):
|
|
||||||
while True:
|
|
||||||
item = await self.queue.get()
|
|
||||||
if item is None:
|
|
||||||
break
|
|
||||||
await self._validate_and_save(item)
|
|
||||||
self.queue.task_done()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 调度器设计
|
|
||||||
`SchedulerService` 负责:
|
|
||||||
- 启动/停止验证队列
|
|
||||||
- 定时从数据库拉取存量代理,重新投入验证队列
|
|
||||||
- 协调插件爬取后的验证流程
|
|
||||||
|
|
||||||
```python
|
|
||||||
class SchedulerService:
|
|
||||||
def __init__(self, queue: ValidationQueue, proxy_repo: ProxyRepository):
|
|
||||||
self.queue = queue
|
|
||||||
self.proxy_repo = proxy_repo
|
|
||||||
self.interval_minutes = 30
|
|
||||||
self._task: asyncio.Task | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 数据库设计
|
|
||||||
|
|
||||||
保留 SQLite + aiosqlite,但优化连接管理。
|
|
||||||
|
|
||||||
### 5.1 表结构
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 代理表
|
|
||||||
CREATE TABLE proxies (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ip TEXT NOT NULL,
|
|
||||||
port INTEGER NOT NULL,
|
|
||||||
protocol TEXT DEFAULT 'http',
|
|
||||||
score INTEGER DEFAULT 10,
|
|
||||||
response_time_ms REAL,
|
|
||||||
last_check TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(ip, port)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 插件设置表
|
|
||||||
CREATE TABLE plugin_settings (
|
|
||||||
plugin_id TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER DEFAULT 1,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 系统设置表(JSON 存储)
|
|
||||||
CREATE TABLE settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 连接管理
|
|
||||||
- 使用 `asynccontextmanager` 管理连接生命周期
|
|
||||||
- 每个 HTTP 请求独立获取连接,请求结束后关闭
|
|
||||||
- 调度器/队列等长生命周期组件也定期重建连接(如每 1000 次操作)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. API 设计调整
|
|
||||||
|
|
||||||
保持现有 API 路径基本不变,但路由按资源拆分。
|
|
||||||
|
|
||||||
### 6.1 路由拆分
|
|
||||||
```
|
|
||||||
apiv1/
|
|
||||||
├── __init__.py
|
|
||||||
├── proxies.py # /api/proxies/*
|
|
||||||
├── plugins.py # /api/plugins/*
|
|
||||||
├── scheduler.py # /api/scheduler/*
|
|
||||||
└── settings.py # /api/settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 新增/调整的 API
|
|
||||||
|
|
||||||
#### 插件相关
|
|
||||||
- `GET /api/plugins` — 获取插件列表(含持久化状态)
|
|
||||||
- `PUT /api/plugins/{plugin_id}/toggle` — 切换启用状态(持久化到 DB)
|
|
||||||
- `POST /api/plugins/{plugin_id}/crawl` — 触发爬取(异步,返回任务 ID)
|
|
||||||
- `POST /api/plugins/crawl-all` — 批量爬取
|
|
||||||
|
|
||||||
**关键变更**:爬取接口改为**异步触发**而不是同步等待。因为新爬虫可能爬取数万个代理,同步 HTTP 请求会超时。
|
|
||||||
|
|
||||||
返回示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "爬取任务已启动",
|
|
||||||
"data": {
|
|
||||||
"task_id": "crawl-20250402-001",
|
|
||||||
"queued": 150
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
为了简化前端,第一阶段可以保留同步 API,但内部通过 `asyncio.create_task` 包装,并设置合理的超时(30 秒)。在真正大规模使用时,再迁移到 WebSocket/SSE 推送进度。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 前端架构调整
|
|
||||||
|
|
||||||
### 7.1 新增 Service 层
|
|
||||||
从 Store 中剥离 API 调用逻辑:
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── services/
|
|
||||||
│ ├── proxyService.js # 代理相关 API 调用
|
|
||||||
│ ├── pluginService.js # 插件相关 API 调用
|
|
||||||
│ ├── schedulerService.js
|
|
||||||
│ └── settingService.js
|
|
||||||
├── stores/
|
|
||||||
│ ├── proxy.js # 纯状态管理
|
|
||||||
│ └── plugin.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Store 职责收敛
|
|
||||||
Store 只负责:
|
|
||||||
- 持有状态(`ref/reactive`)
|
|
||||||
- 提供计算属性
|
|
||||||
- 调用 Service,然后更新状态
|
|
||||||
|
|
||||||
### 7.3 API 适配
|
|
||||||
由于后端 API 路径保持不变,前端改动主要是代码组织上的调整,URL 和返回结构尽量兼容。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 目录结构(重构后)
|
|
||||||
|
|
||||||
```
|
|
||||||
ProxyPool/
|
|
||||||
├── main.py # 项目入口
|
|
||||||
├── requirements.txt # Python 依赖
|
|
||||||
├── .env.example # 环境变量示例
|
|
||||||
│
|
|
||||||
├── app/ # 后端代码
|
|
||||||
│ ├── api/ # FastAPI 入口和路由
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── main.py # 应用工厂
|
|
||||||
│ │ ├── lifespan.py # 生命周期管理
|
|
||||||
│ │ ├── deps.py # 依赖注入
|
|
||||||
│ │ ├── errors.py # 统一异常
|
|
||||||
│ │ └── routes/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── proxies.py
|
|
||||||
│ │ ├── plugins.py
|
|
||||||
│ │ ├── scheduler.py
|
|
||||||
│ │ └── settings.py
|
|
||||||
│ │
|
|
||||||
│ ├── core/ # 基础设施
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── config.py # Pydantic Settings
|
|
||||||
│ │ ├── log.py # 日志
|
|
||||||
│ │ ├── db.py # 数据库连接池/上下文
|
|
||||||
│ │ ├── exceptions.py # 业务异常
|
|
||||||
│ │ ├── plugin_system/ # 插件系统
|
|
||||||
│ │ │ ├── __init__.py
|
|
||||||
│ │ │ ├── base.py # BaseCrawlerPlugin
|
|
||||||
│ │ │ └── registry.py # 插件注册中心
|
|
||||||
│ │ └── tasks/ # 任务队列
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── queue.py # ValidationQueue
|
|
||||||
│ │
|
|
||||||
│ ├── models/ # 数据模型
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── schemas.py # Pydantic 模型
|
|
||||||
│ │ └── domain.py # 领域模型
|
|
||||||
│ │
|
|
||||||
│ ├── repositories/ # 数据访问层
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── proxy_repo.py
|
|
||||||
│ │ ├── settings_repo.py
|
|
||||||
│ │ └── task_repo.py
|
|
||||||
│ │
|
|
||||||
│ ├── services/ # 业务逻辑层
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── proxy_service.py
|
|
||||||
│ │ ├── plugin_service.py
|
|
||||||
│ │ ├── scheduler_service.py
|
|
||||||
│ │ └── validator_service.py
|
|
||||||
│ │
|
|
||||||
│ └── plugins/ # 爬虫插件
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── base.py # 通用抓取基类
|
|
||||||
│ ├── fate0.py
|
|
||||||
│ ├── kuaidaili.py
|
|
||||||
│ ├── ip3366.py
|
|
||||||
│ ├── ip89.py
|
|
||||||
│ ├── speedx.py
|
|
||||||
│ ├── yundaili.py
|
|
||||||
│ ├── proxylist_download.py
|
|
||||||
│ └── proxyscrape.py
|
|
||||||
│
|
|
||||||
├── WebUI/ # Vue3 前端
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # API 封装
|
|
||||||
│ │ ├── stores/ # Pinia 状态管理
|
|
||||||
│ │ ├── views/ # 页面组件
|
|
||||||
│ │ ├── router/ # 路由配置
|
|
||||||
│ │ ├── components/ # 通用组件
|
|
||||||
│ │ └── style.css # 全局样式
|
|
||||||
│ ├── index.html
|
|
||||||
│ └── package.json
|
|
||||||
│
|
|
||||||
├── tests/ # 测试目录
|
|
||||||
│ ├── conftest.py
|
|
||||||
│ ├── unit/
|
|
||||||
│ └── integration/
|
|
||||||
│
|
|
||||||
├── script/ # 启动脚本
|
|
||||||
├── db/ # 数据存储
|
|
||||||
├── logs/ # 日志文件
|
|
||||||
└── DESIGN.md # 本文档
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 迁移计划
|
|
||||||
|
|
||||||
### Phase 1: 基础设施(今天完成)
|
|
||||||
1. 重写 `core/config.py` → Pydantic Settings
|
|
||||||
2. 重写 `core/db.py` → 带上下文管理的连接池
|
|
||||||
3. 创建 `models/` 层
|
|
||||||
|
|
||||||
### Phase 2: Repository + Service(今天完成)
|
|
||||||
1. 创建 `repositories/proxy_repo.py`
|
|
||||||
2. 创建 `services/` 下的业务类
|
|
||||||
3. 迁移现有逻辑
|
|
||||||
|
|
||||||
### Phase 3: 插件系统(今天完成,核心)
|
|
||||||
1. 创建 `core/plugin_system/base.py` 和 `registry.py`
|
|
||||||
2. 设计显式注册机制
|
|
||||||
3. 将所有现有插件迁移到新基类
|
|
||||||
|
|
||||||
### Phase 4: 任务队列(今天完成)
|
|
||||||
1. 创建 `ValidationQueue` 和 `WorkerPool`
|
|
||||||
2. 重写 `SchedulerService`
|
|
||||||
|
|
||||||
### Phase 5: API 路由(今天完成)
|
|
||||||
1. 拆分 `api_server.py` 到 `api/routes/`
|
|
||||||
2. 组装新的 `api/main.py`
|
|
||||||
|
|
||||||
### Phase 6: 前端调整(今天完成)
|
|
||||||
1. 拆分 Service 层
|
|
||||||
2. 适配 Store
|
|
||||||
3. 保留现有页面,只改代码组织
|
|
||||||
|
|
||||||
### Phase 7: 清理与验证
|
|
||||||
1. 删除旧的 `api_server.py`, `core/scheduler.py`, `core/sqlite.py` 等
|
|
||||||
2. 运行测试,确保所有功能正常
|
|
||||||
3. 提交代码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 添加新爬虫的标准流程(目标体验)
|
|
||||||
|
|
||||||
假设要添加一个名为 `mynewsource` 的爬虫:
|
|
||||||
|
|
||||||
**Step 1**: 创建文件 `app/plugins/mynewsource.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.core.plugin_system import BaseCrawlerPlugin, ProxyRaw
|
|
||||||
from app.plugins.base import BaseHTTPPlugin # 可选:如果基于 HTTP 爬取
|
|
||||||
|
|
||||||
class MyNewSourcePlugin(BaseHTTPPlugin):
|
|
||||||
name = "mynewsource"
|
|
||||||
display_name = "我的新代理源"
|
|
||||||
description = "从 example.com 爬取免费代理"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.urls = ["https://example.com/proxies"]
|
|
||||||
|
|
||||||
async def crawl(self) -> list[ProxyRaw]:
|
|
||||||
results = []
|
|
||||||
for url in self.urls:
|
|
||||||
html = await self.fetch(url)
|
|
||||||
# ... 解析 html ...
|
|
||||||
results.append(ProxyRaw(ip="1.2.3.4", port=8080, protocol="http"))
|
|
||||||
return results
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2**: 在 `app/plugins/__init__.py` 中注册
|
|
||||||
|
|
||||||
```python
|
|
||||||
from .mynewsource import MyNewSourcePlugin
|
|
||||||
from app.core.plugin_system import registry
|
|
||||||
|
|
||||||
registry.register(MyNewSourcePlugin)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3**: 重启后端服务,前端自动显示新插件。
|
|
||||||
|
|
||||||
无需修改任何路由、服务、数据库表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*文档版本: 1.0*
|
|
||||||
*作者: Kimi Code*
|
|
||||||
*日期: 2026-04-02*
|
|
||||||
270
WebUI/src/components/LatencyHistogram.vue
Normal file
270
WebUI/src/components/LatencyHistogram.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="chart-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">
|
||||||
|
<el-icon class="header-icon"><Histogram /></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>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { InfoFilled, Histogram } from '@element-plus/icons-vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import axios from '../api'
|
||||||
|
|
||||||
|
const chartRef = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
|
let resizeTimer = null
|
||||||
|
const cachedColors = ref(null)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/proxies/latency-distribution')
|
||||||
|
if (res?.data?.ranges) {
|
||||||
|
updateChart(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch latency distribution:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(data) {
|
||||||
|
if (!chartInstance) return
|
||||||
|
|
||||||
|
const colors = cachedColors.value
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
},
|
||||||
|
backgroundColor: 'rgba(24, 28, 37, 0.95)',
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: 13
|
||||||
|
},
|
||||||
|
formatter: (params) => {
|
||||||
|
const item = params[0]
|
||||||
|
return `${item.name}<br/>代理数: <b>${item.value}</b>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
top: '10%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.ranges || [],
|
||||||
|
axisLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
rotate: 0
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: colors.textSecondary
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '代理数',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255,255,255,0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '延迟分布',
|
||||||
|
type: 'bar',
|
||||||
|
data: data.counts || [],
|
||||||
|
barWidth: '50%',
|
||||||
|
itemStyle: {
|
||||||
|
color: (params) => {
|
||||||
|
const colorList = [
|
||||||
|
colors.success,
|
||||||
|
colors.info,
|
||||||
|
colors.primary,
|
||||||
|
colors.warning,
|
||||||
|
colors.danger || '#EF4444'
|
||||||
|
]
|
||||||
|
return colorList[params.dataIndex] || colors.primary
|
||||||
|
},
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: 'rgba(146, 124, 255, 0.3)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: '{c}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 800,
|
||||||
|
animationEasing: 'cubicOut'
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.setOption(option, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
loadColors()
|
||||||
|
if (chartInstance) {
|
||||||
|
fetchData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = echarts.init(chartRef.value)
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => chartInstance,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 420px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 360px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart-card .el-card__header {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -124,6 +124,7 @@ function getChartOption() {
|
|||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
|
confine: true,
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
|
const percent = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : 0
|
||||||
return `${params.name}: ${params.value} (${percent}%)`
|
return `${params.name}: ${params.value} (${percent}%)`
|
||||||
@@ -150,7 +151,7 @@ function getChartOption() {
|
|||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['40%', '65%'],
|
radius: ['40%', '65%'],
|
||||||
center: ['38%', '50%'],
|
center: ['38%', '52%'],
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
@@ -252,17 +253,18 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.chart-card {
|
.chart-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
min-height: 400px;
|
min-height: 420px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card--compact {
|
.chart-card--compact {
|
||||||
min-height: 340px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card--compact .chart-container {
|
.chart-card--compact .chart-container {
|
||||||
height: 300px;
|
height: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card:hover {
|
.chart-card:hover {
|
||||||
@@ -285,9 +287,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
height: 350px;
|
height: 340px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart-card .el-card__header {
|
||||||
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-card class="actions-card" shadow="hover">
|
|
||||||
<template #header>
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">
|
|
||||||
<el-icon class="header-icon"><Lightning /></el-icon>
|
|
||||||
快速操作
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="quick-actions">
|
|
||||||
<button
|
|
||||||
class="action-btn btn-success"
|
|
||||||
@click="$emit('export')"
|
|
||||||
>
|
|
||||||
<span class="btn-content">
|
|
||||||
<el-icon class="btn-icon"><Download /></el-icon>
|
|
||||||
<span class="btn-text">导出代理</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="action-btn btn-warning"
|
|
||||||
@click="$emit('clean')"
|
|
||||||
>
|
|
||||||
<span class="btn-content">
|
|
||||||
<el-icon class="btn-icon"><Delete /></el-icon>
|
|
||||||
<span class="btn-text">清理无效</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Download, Delete, Lightning } from '@element-plus/icons-vue'
|
|
||||||
|
|
||||||
defineEmits(['export', 'clean'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.actions-card {
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
min-height: 400px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-card:hover {
|
|
||||||
border-color: var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-icon {
|
|
||||||
margin-right: 8px;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 56px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
min-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式 */
|
|
||||||
.btn-success {
|
|
||||||
background: var(--success);
|
|
||||||
color: #0F1117;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover {
|
|
||||||
background: #2DD4BF;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: var(--warning);
|
|
||||||
color: #0F1117;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning:hover {
|
|
||||||
background: #FBBF24;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.quick-actions {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
padding: 12px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
height: 44px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.quick-actions {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
268
WebUI/src/components/ScoreDistribution.vue
Normal file
268
WebUI/src/components/ScoreDistribution.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="chart-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">
|
||||||
|
<el-icon class="header-icon"><TrendCharts /></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>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { InfoFilled, TrendCharts } from '@element-plus/icons-vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import axios from '../api'
|
||||||
|
|
||||||
|
const chartRef = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
|
let resizeTimer = null
|
||||||
|
const cachedColors = ref(null)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/proxies/score-distribution')
|
||||||
|
if (res?.data?.ranges) {
|
||||||
|
updateChart(res.data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch score distribution:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(data) {
|
||||||
|
if (!chartInstance) return
|
||||||
|
|
||||||
|
const colors = cachedColors.value
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
},
|
||||||
|
backgroundColor: 'rgba(24, 28, 37, 0.95)',
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: {
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: 13
|
||||||
|
},
|
||||||
|
formatter: (params) => {
|
||||||
|
const item = params[0]
|
||||||
|
return `${item.name}<br/>代理数: <b>${item.value}</b>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
top: '10%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.ranges || [],
|
||||||
|
axisLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: colors.textSecondary
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '代理数',
|
||||||
|
nameTextStyle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255,255,255,0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '评分分布',
|
||||||
|
type: 'bar',
|
||||||
|
data: data.counts || [],
|
||||||
|
barWidth: '50%',
|
||||||
|
itemStyle: {
|
||||||
|
color: (params) => {
|
||||||
|
const colorList = [
|
||||||
|
colors.success,
|
||||||
|
colors.primary,
|
||||||
|
colors.info,
|
||||||
|
colors.warning,
|
||||||
|
colors.danger || '#EF4444'
|
||||||
|
]
|
||||||
|
return colorList[params.dataIndex] || colors.primary
|
||||||
|
},
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowColor: 'rgba(146, 124, 255, 0.3)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: '{c}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
animation: true,
|
||||||
|
animationDuration: 800,
|
||||||
|
animationEasing: 'cubicOut'
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.setOption(option, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
loadColors()
|
||||||
|
if (chartInstance) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = echarts.init(chartRef.value)
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => chartInstance,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 420px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 360px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart-card .el-card__header {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
269
WebUI/src/components/ValidationGauge.vue
Normal file
269
WebUI/src/components/ValidationGauge.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="chart-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">
|
||||||
|
<el-icon class="header-icon"><DataAnalysis /></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>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { InfoFilled, DataAnalysis } from '@element-plus/icons-vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartRef = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
|
let resizeTimer = null
|
||||||
|
const cachedColors = ref(null)
|
||||||
|
|
||||||
|
const successRate = computed(() => {
|
||||||
|
const available = props.data?.available || 0
|
||||||
|
const invalid = props.data?.invalid_count || 0
|
||||||
|
const total = available + invalid
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.round((available / total) * 100 * 10) / 10
|
||||||
|
})
|
||||||
|
|
||||||
|
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'),
|
||||||
|
danger: getCssVar('--danger', '#EF4444'),
|
||||||
|
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
|
||||||
|
const rate = successRate.value
|
||||||
|
|
||||||
|
const getColor = (val) => {
|
||||||
|
if (val >= 80) return colors.success
|
||||||
|
if (val >= 50) return colors.warning
|
||||||
|
return colors.danger
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'gauge',
|
||||||
|
startAngle: 200,
|
||||||
|
endAngle: -20,
|
||||||
|
radius: '90%',
|
||||||
|
center: ['50%', '60%'],
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
splitNumber: 5,
|
||||||
|
itemStyle: {
|
||||||
|
color: getColor(rate)
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
show: true,
|
||||||
|
width: 18,
|
||||||
|
roundCap: true
|
||||||
|
},
|
||||||
|
pointer: {
|
||||||
|
show: true,
|
||||||
|
length: '60%',
|
||||||
|
width: 6,
|
||||||
|
itemStyle: {
|
||||||
|
color: colors.textSecondary
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 18,
|
||||||
|
color: [[1, colors.surface]]
|
||||||
|
},
|
||||||
|
roundCap: true
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: true,
|
||||||
|
distance: -20,
|
||||||
|
length: 6,
|
||||||
|
lineStyle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
width: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
distance: -24,
|
||||||
|
length: 10,
|
||||||
|
lineStyle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
width: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: true,
|
||||||
|
distance: -35,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: '{value}%'
|
||||||
|
},
|
||||||
|
anchor: {
|
||||||
|
show: true,
|
||||||
|
size: 10,
|
||||||
|
itemStyle: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.surface
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
show: true,
|
||||||
|
offsetCenter: [0, '15%'],
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textSecondary
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
show: true,
|
||||||
|
offsetCenter: [0, '-10%'],
|
||||||
|
valueAnimation: true,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: getColor(rate),
|
||||||
|
formatter: '{value}%'
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: rate,
|
||||||
|
name: '验证通过率'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
loadColors()
|
||||||
|
if (chartInstance) {
|
||||||
|
updateChart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = echarts.init(chartRef.value)
|
||||||
|
updateChart()
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart() {
|
||||||
|
if (!chartInstance) 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) {
|
||||||
|
initChart()
|
||||||
|
} else {
|
||||||
|
updateChart()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyChart()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
min-height: 280px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,25 +47,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-row :gutter="20" class="charts-row">
|
|
||||||
<el-col :xs="24" :lg="16">
|
|
||||||
<el-row :gutter="16" class="charts-inner">
|
|
||||||
<el-col :xs="24" :md="12">
|
|
||||||
<ProtocolChart :data="stats" variant="available" compact />
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :md="12">
|
|
||||||
<ProtocolChart :data="stats" variant="pending" compact />
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="24" :lg="8">
|
|
||||||
<QuickActions
|
|
||||||
@export="handleExport"
|
|
||||||
@clean="handleClean"
|
|
||||||
/>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 系统状态 -->
|
<!-- 系统状态 -->
|
||||||
<el-row :gutter="20" class="status-row">
|
<el-row :gutter="20" class="status-row">
|
||||||
<el-col :xs="24">
|
<el-col :xs="24">
|
||||||
@@ -76,6 +57,16 @@
|
|||||||
<el-icon><InfoFilled /></el-icon>
|
<el-icon><InfoFilled /></el-icon>
|
||||||
系统状态
|
系统状态
|
||||||
</span>
|
</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="success" @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出代理
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" @click="handleClean">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
清理无效
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="status-list">
|
<div class="status-list">
|
||||||
@@ -105,6 +96,25 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="charts-row">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<ProtocolChart :data="stats" variant="available" compact />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<ProtocolChart :data="stats" variant="pending" compact />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 延迟分布和评分分布 -->
|
||||||
|
<el-row :gutter="20" class="charts-row">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<LatencyHistogram />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<ScoreDistribution />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -120,13 +130,16 @@ import {
|
|||||||
InfoFilled,
|
InfoFilled,
|
||||||
Clock,
|
Clock,
|
||||||
Odometer,
|
Odometer,
|
||||||
WarningFilled
|
WarningFilled,
|
||||||
|
Download,
|
||||||
|
Delete
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useProxyStore } from '../stores/proxy'
|
import { useProxyStore } from '../stores/proxy'
|
||||||
import { formatNumber } from '../utils/format'
|
import { formatNumber } from '../utils/format'
|
||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import ProtocolChart from '../components/ProtocolChart.vue'
|
import ProtocolChart from '../components/ProtocolChart.vue'
|
||||||
import QuickActions from '../components/QuickActions.vue'
|
import LatencyHistogram from '../components/LatencyHistogram.vue'
|
||||||
|
import ScoreDistribution from '../components/ScoreDistribution.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import { useStatsWebSocket } from '../composables/useStatsWebSocket'
|
import { useStatsWebSocket } from '../composables/useStatsWebSocket'
|
||||||
|
|
||||||
@@ -143,7 +156,8 @@ const latencyLabel = computed(() => {
|
|||||||
if (ms == null || ms === '' || Number(ms) <= 0) {
|
if (ms == null || ms === '' || Number(ms) <= 0) {
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
return `${formatNumber(Number(ms), 1)} ms`
|
const seconds = Number(ms) / 1000
|
||||||
|
return `${formatNumber(seconds, 2)} s`
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
@@ -220,6 +234,9 @@ onMounted(async () => {
|
|||||||
.card-header {
|
.card-header {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -228,6 +245,11 @@ onMounted(async () => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-list {
|
.status-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -262,3 +284,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-card .el-card__header {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -108,3 +108,21 @@ async def batch_delete(
|
|||||||
async def clean_invalid(service: ProxyService = Depends(get_proxy_service)):
|
async def clean_invalid(service: ProxyService = Depends(get_proxy_service)):
|
||||||
count = await service.clean_invalid()
|
count = await service.clean_invalid()
|
||||||
return success_response(f"清理了 {count} 个无效代理", {"deleted_count": count})
|
return success_response(f"清理了 {count} 个无效代理", {"deleted_count": count})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/latency-distribution")
|
||||||
|
async def get_latency_distribution(
|
||||||
|
service: ProxyService = Depends(get_proxy_service),
|
||||||
|
):
|
||||||
|
"""获取延迟分布数据,用于直方图展示"""
|
||||||
|
distribution = await service.get_latency_distribution()
|
||||||
|
return success_response("获取延迟分布成功", distribution)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/score-distribution")
|
||||||
|
async def get_score_distribution(
|
||||||
|
service: ProxyService = Depends(get_proxy_service),
|
||||||
|
):
|
||||||
|
"""获取评分分布数据,用于柱状图展示"""
|
||||||
|
distribution = await service.get_score_distribution()
|
||||||
|
return success_response("获取评分分布成功", distribution)
|
||||||
|
|||||||
@@ -526,3 +526,73 @@ class ProxyRepository:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"clean_expired failed: {e}", exc_info=True)
|
logger.error(f"clean_expired failed: {e}", exc_info=True)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_latency_distribution(db: aiosqlite.Connection) -> dict:
|
||||||
|
"""获取延迟分布数据(仅已验证可用的代理)"""
|
||||||
|
try:
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT response_time_ms FROM proxies
|
||||||
|
WHERE validated = 1 AND score > 0 AND response_time_ms IS NOT NULL AND response_time_ms > 0
|
||||||
|
"""
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
if not rows:
|
||||||
|
return {"ranges": [], "counts": []}
|
||||||
|
|
||||||
|
latencies = [row[0] for row in rows]
|
||||||
|
ranges = ["<500ms", "500-1s", "1-2s", "2-3s", ">3s"]
|
||||||
|
counts = [0, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
for lat in latencies:
|
||||||
|
if lat < 500:
|
||||||
|
counts[0] += 1
|
||||||
|
elif lat < 1000:
|
||||||
|
counts[1] += 1
|
||||||
|
elif lat < 2000:
|
||||||
|
counts[2] += 1
|
||||||
|
elif lat < 3000:
|
||||||
|
counts[3] += 1
|
||||||
|
else:
|
||||||
|
counts[4] += 1
|
||||||
|
|
||||||
|
return {"ranges": ranges, "counts": counts}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_latency_distribution failed: {e}", exc_info=True)
|
||||||
|
return {"ranges": [], "counts": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_score_distribution(db: aiosqlite.Connection) -> dict:
|
||||||
|
"""获取评分分布数据(仅已验证可用的代理)"""
|
||||||
|
try:
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT score FROM proxies
|
||||||
|
WHERE validated = 1 AND score > 0
|
||||||
|
"""
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
if not rows:
|
||||||
|
return {"ranges": [], "counts": []}
|
||||||
|
|
||||||
|
scores = [row[0] for row in rows]
|
||||||
|
ranges = ["80-100", "60-80", "40-60", "20-40", "0-20"]
|
||||||
|
counts = [0, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
for score in scores:
|
||||||
|
if score >= 80:
|
||||||
|
counts[0] += 1
|
||||||
|
elif score >= 60:
|
||||||
|
counts[1] += 1
|
||||||
|
elif score >= 40:
|
||||||
|
counts[2] += 1
|
||||||
|
elif score >= 20:
|
||||||
|
counts[3] += 1
|
||||||
|
else:
|
||||||
|
counts[4] += 1
|
||||||
|
|
||||||
|
return {"ranges": ranges, "counts": counts}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"get_score_distribution failed: {e}", exc_info=True)
|
||||||
|
return {"ranges": [], "counts": []}
|
||||||
|
|||||||
@@ -147,3 +147,11 @@ class ProxyService:
|
|||||||
if isinstance(dt, str):
|
if isinstance(dt, str):
|
||||||
return dt
|
return dt
|
||||||
return dt.isoformat()
|
return dt.isoformat()
|
||||||
|
|
||||||
|
async def get_latency_distribution(self) -> dict:
|
||||||
|
async with get_db() as db:
|
||||||
|
return await self.proxy_repo.get_latency_distribution(db)
|
||||||
|
|
||||||
|
async def get_score_distribution(self) -> dict:
|
||||||
|
async with get_db() as db:
|
||||||
|
return await self.proxy_repo.get_score_distribution(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user