提交 01c44f17 authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim,performance): batch_start_simulators 并行化 + 性能测试项目管理与批量执行

设备模拟模块:
- batch_start_simulators 改为 ThreadPoolExecutor 并行启动,540 台设备从 27 分钟缩短到 ~3 秒
- 每个线程独立 asyncio 事件循环 + 独立 DB 会话,避免同步 HTTP 阻塞和线程安全问题
- asyncio.Semaphore(50) 控制并发,防止 MQTT Broker / Token API 过载
- mqtt_manager on_disconnect 移除阻塞 sleep() 和手动 reconnect()

性能测试模块:
- 新增性能测试项目管理与批量执行功能
- 前端 4 个页面(Projects / ProjectDetail / TaskList / App.vue)
- 后端路由、服务、模型、Schema 适配
- 2 份 PRD 需求文档 + 执行计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3bdacbda
# PRD - 性能测试项目管理与批量执行
> **文档版本**: v1.0
> **创建日期**: 2026-08-20
> **模块类型**: 性能测试
> **优先级**: P1
---
## 一、背景与目标
### 1.1 背景
当前性能测试模块的任务管理采用**平铺列表**方式,所有任务混在一起,缺乏层级组织能力。随着性能测试任务数量增长,用户面临以下问题:
1. **任务归属不清晰**:无法区分「标准版」与「项目定制」场景的任务
2. **无法批量操作**:每次只能执行单个任务,需要反复操作
3. **缺乏项目级视角**:不能直观查看某个项目下所有接口的性能概况
UI 自动化模块已通过 `module_type: standard/custom` 实现了「标准模块」与「项目定制模块」的区分,并支持整模块执行和模块内多选执行。性能测试模块需要类似的层级管理能力。
### 1.2 目标
1. **项目化管理**:将性能测试任务组织到「项目」中,区分标准版/项目定制场景
2. **整项目执行**:一键执行项目下所有任务(顺序执行)
3. **批量勾选执行**:进入项目后,勾选多个任务批量执行
4. **兼容现有数据**:已有任务可归入项目,不破坏现有数据
---
## 二、功能范围
| 功能 | 描述 | 优先级 |
|------|------|--------|
| 性能项目管理(CRUD) | 创建/编辑/删除项目,区分标准版/项目定制 | P0 |
| 项目卡片网格视图 | 类似 Modules.vue 的卡片布局展示项目 | P0 |
| 任务归属项目 | 创建/编辑任务时选择归属项目 | P0 |
| 项目内任务列表 | 进入项目后查看该项目的所有任务 | P0 |
| 整项目执行 | 一键顺序执行项目下所有任务 | P0 |
| 多任务勾选批量执行 | 勾选多个任务后批量执行(顺序执行) | P0 |
| 批量执行进度展示 | 展示当前执行到第几个任务、整体进度 | P1 |
| 任务列表按项目筛选 | 全局任务列表支持按项目维度过滤 | P1 |
| 项目维度统计 | 项目卡片展示任务数、最近执行状态等 | P1 |
---
## 三、数据模型设计
### 3.1 PerformanceProject(新表)
```sql
CREATE TABLE performance_projects (
id VARCHAR(64) PRIMARY KEY, -- 唯一标识 (proj_xxx)
name VARCHAR(200) NOT NULL, -- 项目名称
description TEXT DEFAULT '', -- 项目描述
project_type VARCHAR(20) DEFAULT 'standard', -- 类型: standard(标准)/custom(项目定制)
icon VARCHAR(50) DEFAULT 'folder', -- 项目图标
sort_order INT DEFAULT 0, -- 排序序号
created_at DATETIME, -- 创建时间
updated_at DATETIME -- 更新时间
);
```
### 3.2 PerformanceTask(扩展字段)
在现有 `performance_tasks` 表的基础上新增**一个字段**
```sql
ALTER TABLE performance_tasks ADD COLUMN project_id VARCHAR(64) DEFAULT NULL;
```
**说明**
- `project_id` 为 nullable,不强制所有任务都归属项目
- 删除项目时,其下任务的 `project_id` 置为 NULL(不级联删除任务)
- 现有任务不受影响,迁移时用户可手动将任务归入项目
### 3.3 批量执行记录(可选,P1)
```sql
CREATE TABLE performance_batch_executions (
id VARCHAR(64) PRIMARY KEY, -- 批量执行ID
project_id VARCHAR(64), -- 关联项目ID
task_ids TEXT, -- 执行的任务ID列表(JSON数组)
status VARCHAR(20) DEFAULT 'running', -- running/completed/partially_failed
current_index INT DEFAULT 0, -- 当前执行到第几个
total_count INT DEFAULT 0, -- 总任务数
success_count INT DEFAULT 0, -- 成功数
fail_count INT DEFAULT 0, -- 失败数
started_at DATETIME, -- 开始时间
completed_at DATETIME, -- 完成时间
created_at DATETIME -- 创建时间
);
```
---
## 四、API 设计
### 4.1 项目管理
| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/api/performance/projects` | 获取项目列表(支持 project_type 筛选) |
| POST | `/api/performance/projects` | 创建项目 |
| GET | `/api/performance/projects/{id}` | 获取项目详情 |
| PUT | `/api/performance/projects/{id}` | 更新项目 |
| DELETE | `/api/performance/projects/{id}` | 删除项目(任务置空 project_id) |
| GET | `/api/performance/projects/{id}/stats` | 获取项目统计(任务数、各状态分布) |
### 4.2 任务列表扩展
| 参数 | 描述 |
|------|------|
| `project_id` | 按项目筛选任务(现有 `list_tasks` 新增参数) |
| `project_id=null` | 查询未归属项目的任务 |
### 4.3 批量执行
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/api/performance/projects/{id}/run-all` | 执行项目下所有任务 |
| POST | `/api/performance/tasks/batch-run` | 批量执行指定任务列表 |
**批量执行流程**
1. 接收请求,返回 `batch_id``task_ids` 列表
2. 后台按顺序逐个执行任务(串行,避免资源竞争)
3. 每个任务执行完毕后更新进度
4. 全部完成后推送批量执行完成通知
5. 可通过 WebSocket 订阅批量执行进度(`perf_batch_progress`
### 4.4 WebSocket 消息扩展
```json
{
"type": "perf_batch_progress",
"batch_id": "batch_xxx",
"project_id": "proj_xxx",
"current_index": 2,
"total_count": 5,
"current_task_id": "perf_xxx",
"current_task_name": "获取设备列表",
"current_status": "completed",
"success_count": 2,
"fail_count": 0
}
```
---
## 五、前端页面设计
### 5.1 页面结构
```
/performance
├── /performance/projects ← 项目列表页(卡片网格,新增)
├── /performance/projects/:id ← 项目详情页(项目内任务列表,新增)
├── /performance/tasks ← 全局任务列表(现有,新增 project_id 筛选)
├── /performance/monitor ← 监控页(现有)
├── /performance/report ← 报告页(现有)
└── /performance/presets ← 预设配置(现有)
```
### 5.2 项目列表页(/performance/projects)
- **顶部 tab**:标准版 / 项目定制(类似 Modules.vue 的 `:type` 参数)
- **卡片网格**:每行 4 张卡片(el-col span=6)
- 显示项目名称、描述、图标
- 显示统计信息:任务数、最近执行状态
- 操作:进入项目(点击卡片)、编辑、删除
- **工具栏**:新建项目按钮、刷新按钮
### 5.3 项目详情页(/performance/projects/:id)
- **顶部区域**:项目名称、描述、返回按钮
- **工具栏**
- 「执行全部」按钮(一键执行项目下所有任务)
- 批量执行按钮(勾选后出现)
- 新建任务按钮(自动设置 project_id)
- 状态筛选、刷新
- **任务表格**:在现有 TaskList 表格基础上增加:
- 多选框列(用于批量勾选)
- 显示任务状态、TPS、错误率等
- **批量执行进度条**:执行中显示进度(当前第 N 个 / 共 M 个)
### 5.4 任务创建/编辑弹窗扩展
- 新增「归属项目」下拉选择器(可选,不强制)
- 在现有创建弹窗中增加一个字段
### 5.5 全局任务列表调整
- 任务列表页增加「项目」列,显示项目名称
- 增加按项目筛选的下拉选择器
---
## 六、UI 预览图
> 详见同目录下 `_PRD_性能测试项目管理与批量执行_UI预览.html`
> 可使用浏览器打开查看完整页面设计
---
## 七、非功能需求
### 7.1 兼容性
- 新增 `project_id` 字段为 nullable,现有任务不强制迁移
- 删除项目不级联删除任务,仅置空 `project_id`
- 所有现有 API 保持向后兼容
### 7.2 性能约束
- 批量执行采用**串行**方式(一个任务完成后才执行下一个),避免多个压测引擎同时运行导致资源竞争
- 批量执行进度通过 WebSocket 实时推送
### 7.3 安全性
- 正在运行的任务不可编辑/删除(现有约束)
- 批量执行中如果某个任务失败,继续执行下一个(不中断)
---
## 八、实施计划
| 阶段 | 内容 | 预估工时 |
|------|------|---------|
| Phase 1 | 数据模型 + 后端 CRUD API | 1 天 |
| Phase 2 | 项目列表页 + 项目详情页 UI | 1 天 |
| Phase 3 | 批量执行 + 进度推送 | 1 天 |
| Phase 4 | 现有任务列表适配 + 联调测试 | 0.5 天 |
---
*本文档完成后,将使用 prd-plan skill 生成计划执行文档,再使用 prd-code skill 实现代码。*
\ No newline at end of file
# 执行计划 - 性能测试项目管理与批量执行
> **关联 PRD**:`_PRD_性能测试项目管理与批量执行.md`
> **创建日期**:2026-08-20
> **预计总工时**:3.5 天
---
## 一、执行概述
本计划将 PRD 中的「性能测试项目管理与批量执行」功能分为 4 个 Phase 实施:
1. **Phase 1**:数据模型 + 后端 CRUD API(1 天)
2. **Phase 2**:项目列表页 + 项目详情页 UI(1 天)
3. **Phase 3**:批量执行 + 进度推送(1 天)
4. **Phase 4**:现有任务列表适配 + 联调测试(0.5 天)
每个 Phase 完成后可独立验收,Phase 1 是后续所有 Phase 的基础。
---
## 二、任务分解
### Phase 1:数据模型 + 后端 CRUD API
**目标**:新建 PerformanceProject 模型,扩展 PerformanceTask 加 project_id 字段,实现项目 CRUD API。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 1.1 | 新建 `PerformanceProject` ORM 模型 | `backend/app/models/performance.py` | 表字段与 PRD 3.1 一致(id/name/description/project_type/icon/sort_order/created_at/updated_at) |
| 1.2 | 扩展 `PerformanceTask` 模型增加 `project_id` 字段 | `backend/app/models/performance.py` | project_id VARCHAR(64) DEFAULT NULL,to_dict() 输出含 project_id |
| 1.3 | 数据库迁移脚本(SQLite ALTER TABLE) | `backend/scripts/migrate_add_perf_project.py` | 运行后 performance_projects 表存在,performance_tasks 有 project_id 列 |
| 1.4 | 新建 Project CRUD schemas | `backend/app/schemas/performance.py` | PerformanceProjectCreate/Update/Response,camelCase alias |
| 1.5 | 新建 Project CRUD 路由 + Service | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | GET/POST/PUT/DELETE /api/performance/projects 全部可用 |
| 1.6 | 项目统计接口 | 同上 | GET /api/performance/projects/{id}/stats 返回任务数、状态分布 |
| 1.7 | list_tasks 增加 project_id 筛选参数 | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | 传 project_id 筛选、传 null 查未归属任务、不传返回全部(向后兼容) |
| 1.8 | 创建/更新任务 schema 增加 project_id 字段 | `backend/app/schemas/performance.py` | PerformanceTaskCreate/Update 含可选 project_id |
**验收方式**:通过 http://localhost:8001/docs 的 Swagger UI 手动测试所有项目 CRUD 接口。
---
### Phase 2:项目列表页 + 项目详情页 UI
**目标**:新建两个前端页面,实现项目卡片网格和项目内任务列表。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 2.1 | 新增 PerformanceProject 类型定义 | `frontend/src/types/performance.ts` | 接口与后端 schema 对应 |
| 2.2 | 新增项目 API 调用函数 | `frontend/src/api/performance.ts` | projects CRUD + stats + listTasks(projectId) |
| 2.3 | 新建项目列表页 `Projects.vue` | `frontend/src/views/performance/Projects.vue` | 卡片网格布局,顶部 tab(标准版/项目定制),每行 4 张卡片,含编辑/删除操作 |
| 2.4 | 新建项目详情页 `ProjectDetail.vue` | `frontend/src/views/performance/ProjectDetail.vue` | 顶部项目信息,工具栏(执行全部/新建任务),任务表格(含 checkbox 列) |
| 2.5 | 注册路由 | `frontend/src/router/index.ts` | /performance/projects 和 /performance/projects/:id 可访问 |
| 2.6 | 侧边栏菜单更新 | `frontend/src/App.vue` 或菜单配置 | 性能测试下出现「项目管理」子菜单入口 |
**验收方式**:浏览器访问 /performance/projects,能看到卡片网格;点击卡片进入详情页,能看到任务列表。
---
### Phase 3:批量执行 + 进度推送
**目标**:实现整项目执行和多任务批量执行,通过 WebSocket 推送进度。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 3.1 | 新建批量执行记录模型(可选 P1) | `backend/app/models/performance.py` | performance_batch_executions 表创建 |
| 3.2 | 实现 run-all API(项目下所有任务串行执行) | `backend/app/routers/performance.py`, `backend/app/services/performance_service.py` | POST /api/performance/projects/{id}/run-all 返回 batch_id |
| 3.3 | 实现 batch-run API(指定任务列表串行执行) | 同上 | POST /api/performance/tasks/batch-run 接收 task_ids 数组 |
| 3.4 | WebSocket 批量进度消息 | `backend/app/websocket/manager.py` | 消息类型 perf_batch_progress,含 batch_id/current_index/total_count 等 |
| 3.5 | 前端「执行全部」按钮逻辑 | `ProjectDetail.vue` | 点击后调用 run-all,监听 WebSocket 显示进度条 |
| 3.6 | 前端「批量执行」按钮逻辑 | `ProjectDetail.vue` | 勾选任务后出现批量执行按钮,点击调用 batch-run,显示进度条 |
| 3.7 | 批量执行进度 UI 组件 | `ProjectDetail.vue` | 进度条显示「当前第 N 个 / 共 M 个」,成功/失败计数 |
**验收方式**:在项目详情页勾选多个任务,点击批量执行,观察进度条实时更新;点击「执行全部」同理。
---
### Phase 4:现有列表适配 + 联调测试
**目标**:全局任务列表增加项目列和筛选,整体联调。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 4.1 | 全局任务列表增加「归属项目」列 | `frontend/src/views/performance/TaskList.vue` | 表格显示项目名称(el-tag) |
| 4.2 | 全局任务列表增加项目筛选下拉 | `frontend/src/views/performance/TaskList.vue` | 下拉选择项目可筛选,选「全部」返回所有 |
| 4.3 | 创建/编辑任务弹窗增加项目选择 | `frontend/src/views/performance/TaskList.vue` 或组件 | 弹窗中有「归属项目」下拉选择器 |
| 4.4 | 整体联调 | 全部相关文件 | 创建项目 → 创建任务归入项目 → 项目详情页查看 → 批量执行 → 全局列表筛选均正常 |
| 4.5 | 数据迁移验证 | `backend/scripts/migrate_add_perf_project.py` | 已有数据不受影响,project_id 为 NULL |
**验收方式**:端到端流程——创建项目、创建任务关联项目、在项目详情页批量执行、在全局列表按项目筛选。
---
## 三、验收标准总览
| 验收项 | 对应 Phase |
|--------|-----------|
| 项目 CRUD API 全部可用 | Phase 1 |
| list_tasks 支持 project_id 筛选且向后兼容 | Phase 1 |
| 项目列表页卡片网格正常展示 | Phase 2 |
| 项目详情页任务列表正常展示 | Phase 2 |
| 整项目执行(run-all)串行执行完成 | Phase 3 |
| 多任务批量执行(batch-run)串行执行完成 | Phase 3 |
| WebSocket 实时推送批量进度 | Phase 3 |
| 全局任务列表显示项目列 + 支持筛选 | Phase 4 |
| 创建/编辑任务可选择归属项目 | Phase 4 |
| 已有数据不被破坏 | Phase 1, 4 |
---
## 四、测试计划
### 单元测试
- Phase 1 完成后:Project CRUD Service 测试
- Phase 3 完成后:批量执行逻辑测试
### 集成测试
- Phase 4 完成后:端到端流程测试
- 创建项目 → 创建任务 → 关联项目 → 批量执行 → 查看结果
### 手动测试
- 各 Phase 完成后通过 Swagger UI + 前端页面手动验证
---
## 五、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 批量执行串行耗时长 | 用户等待时间长 | WebSocket 实时推送进度,支持中途取消(可选) |
| 多任务执行资源竞争 | 压测引擎互相干扰 | 严格串行,一个完成才执行下一个 |
| 数据库锁(SQLite) | 并发写入失败 | 利用现有 pool_pre_ping + check_same_thread 配置 |
| 现有 API 兼容性 | 前端调用异常 | project_id 为 nullable,不传时返回全部(向后兼容) |
---
## 六、后续工作
- [ ] Phase 1-4 实施完成后更新 CLAUDE.md 当前进度
- [ ] 更新 HANDOFF.md 交接文档
- [ ] Git 提交(Conventional Commits)
- [ ] 部署到 5.60 服务器(如需要)
---
## 七、附录
### 关键数据流
```
创建项目 → POST /api/performance/projects
创建任务(带 project_id)→ POST /api/performance/tasks
项目详情页 → GET /api/performance/projects/{id} + list_tasks(project_id)
批量执行 → POST /api/performance/tasks/batch-run
WebSocket 进度推送 ← perf_batch_progress
```
### 参考文件
| 文件 | 用途 |
|------|------|
| `frontend/src/views/modules/Modules.vue` | 卡片网格布局参考 |
| `frontend/src/api/modules.ts` | API 调用模式参考 |
| `backend/app/services/module_service.py` | Service 层 CRUD 参考 |
| `backend/app/models/module.py` | Module 模型参考(module_type) |
| `backend/app/services/performance_service.py` | 现有性能服务(_run_sync 串行执行逻辑) |
---
*本文档由 prd-plan skill 生成,关联 PRD:`_PRD_性能测试项目管理与批量执行.md`*
...@@ -879,6 +879,109 @@ if existing and existing.connected and existing.client: ...@@ -879,6 +879,109 @@ if existing and existing.connected and existing.client:
--- ---
### 2026-08-20 会话 J:修复全部启动功能失败 — token_api_host 错误回退 + MQTT on_disconnect 阻塞修复(代码修复 + 部署验证完成)
**会话目标**:修复"全部启动"功能一直启动不起来的问题
**状态**:✅ 问题分析 + 执行计划文档完成;✅ 代码修复(token_api_host 回退 + mqtt_manager on_disconnect 阻塞);✅ 部署到 192.168.5.60 并验证通过;⚠️ MQTT 连接闪断(rc=7)为 Broker 端问题,非 app 代码缺陷
---
#### 问题 1:token_api_host 错误回退导致全部启动阻塞
**现象**:点击门口屏或无纸化的"全部启动"按钮后,页面长时间无响应,设备状态不更新。
**根因**`device_sim_service.py` 第 843 行:
```python
# 修复前 — 环境配置未设置 token_api_host 时回退为 broker_host
token_api_host = config.default_topic_params.get("token_api_host") or config.broker_host
```
当环境配置 `default_topic_params` 中不含 `token_api_host`(如 5.60 服务器的环境配置),回退为 MQTT Broker 地址 `192.168.5.44`,门口屏/无纸化模拟器 `start()` 判断 `if not token_host:` 为 False → 进入 token 获取分支 → 调用 Token API 到 `192.168.5.44`(不是 Token API 服务器)→ 每台设备串行失败。
**修复**`device_sim_service.py` 第 843 行):
```python
# 修复后 — 未配置时保持 None,模拟器跳过 token 获取直接走 MQTT
token_api_host = config.default_topic_params.get("token_api_host")
```
---
#### 问题 2:mqtt_manager.py on_disconnect 回调阻塞导致启动卡死
**现象**:修复问题 1 后,小批量启动(4 台)成功,但 MQTT 连接持续闪断(每 ~3 秒连接→断开→重连)。
**根因**`mqtt_manager.py``on_disconnect` 回调中:
```python
# 修复前 — 阻塞 paho 网络循环线程 + 与内置重连冲突
time.sleep(delay) # 阻塞 paho 网络线程
client.reconnect() # 与 reconnect_delay_set 自动重连冲突
```
`on_disconnect` 运行在 paho 的网络循环线程上,`time.sleep()` 阻塞该线程导致:
1. paho 的 `reconnect_delay_set(min_delay=1, max_delay=60)` 内置自动重连被阻塞
2. 手动 `reconnect()` 与 paho 自动重连同时触发,可能导致重复连接被 Broker 踢掉
**修复**`mqtt_manager.py` `on_disconnect`):
```python
# 修复后 — 不阻塞、不手动重连,由 paho 内置机制处理
def on_disconnect(self, client, userdata, rc):
with self._lock:
self.connected = False
if rc != 0:
logger.warning(f"MQTT 意外断开: env_config_id={self.env_config_id}, rc={rc}, 等待自动重连...")
```
---
#### 已知问题:MQTT 连接闪断(rc=7 = MQTT_ERR_CONN_LOST)
经独立测试确认,MQTT 到 192.168.5.44:1883 的连接每 ~3 秒被 Broker 主动断开,与订阅/发布/client_id 均无关。这是 **Broker 端的问题**(如 idle_timeout、duplicate client 检测、ACL kick 等),非 app 代码缺陷。
- 连接 3 秒后断开 → paho 自动重连 → 连接 3 秒后再次断开,循环
- 设备能成功启动(小批量测试 4/4 成功),但 540 台串行全部启动超时(540 × 3s ≈ 27 分钟 > 120s 超时)
- 建议后续:① 排查 Broker 配置 ② batch_start 改为并行启动 ③ 或切换到 5.60 本地 EMQX
---
#### 部署操作
1. 修复 `deploy_door_http_final.py`,补充 `device_sim_service.py` 到上传列表(共 8 个后端文件)
2. 修复 `mqtt_manager.py` on_disconnect 回调
3. SFTP 上传 8 个后端文件到 5.60 服务器 → `docker compose restart app`
4. 容器重启后状态:`Up (healthy)`
---
#### 验证结果
| 验证项 | 结果 |
|--------|------|
| 小批量启动 4 台门口屏 | ✅ 成功 4/4,无阻塞 |
| 小批量启动 6 台门口屏 | ✅ 成功 4/6(2 台 ID 不存在) |
| 540 台全部启动(start-all) | ⚠️ 超时(Broker 闪断 + 串行启动太慢) |
| MQTT 连接稳定性 | ⚠️ 每 ~3 秒断连(Broker 端问题) |
| 设备统计 API | ✅ 正确返回 1040 台设备统计 |
---
**修改文件清单(本次会话)**
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 843 行删除 `or config.broker_host`,token_api_host 未配置时保持 None |
| `backend/app/services/mqtt_manager.py` | 修改 | `on_disconnect` 移除 `time.sleep()` + `client.reconnect()`,由 paho 内置自动重连处理 |
| `deploy_door_http_final.py` | 修改 | BACKEND_FILES 列表新增 `device_sim_service.py`(共 8 个文件) |
| `Docs/PRD/设备模拟/问题处理/问题分析_token_api_host错误回退导致全部启动失败.md` | 新增 | 问题分析文档 |
| `Docs/PRD/设备模拟/执行计划/执行计划_修复token_api_host错误回退导致全部启动失败.md` | 新增 | 执行计划文档 |
**待办**
- ⚠️ 本次改动未提交 git,下次会话 `/GitCommit` 提交
- ⚠️ 未部署到生产环境(5.60 服务器已部署)
- P0:排查 192.168.5.44 Broker 闪断原因(rc=7 = MQTT_ERR_CONN_LOST)
- P1:`batch_start_simulators` 改为并行启动(asyncio.gather + semaphore 控制并发),避免 540 台串行超时
- P2:考虑使用 5.60 本地 EMQX 替代 192.168.5.44 的 Broker
---
### 2026-08-20 会话 H:修复部署遗漏导致门口屏启动失败(已全部完成并部署) ### 2026-08-20 会话 H:修复部署遗漏导致门口屏启动失败(已全部完成并部署)
**会话目标**:修复门口屏启动报错 `BaseSimulator.__init__() takes from 5 to 8 positional arguments but 9 were given`,因 `deploy_door_http_final.py` 部署时遗漏 `base_simulator.py` 所致。 **会话目标**:修复门口屏启动报错 `BaseSimulator.__init__() takes from 5 to 8 positional arguments but 9 were given`,因 `deploy_door_http_final.py` 部署时遗漏 `base_simulator.py` 所致。
......
...@@ -191,6 +191,9 @@ async def _ensure_columns(conn) -> None: ...@@ -191,6 +191,9 @@ async def _ensure_columns(conn) -> None:
("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"), ("api_presets", "credential_name", "VARCHAR(100) DEFAULT NULL"),
("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"), ("api_presets", "login_username", "VARCHAR(200) DEFAULT NULL"),
("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"), ("api_presets", "login_password", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:任务归属项目ID(旧库升级)
("performance_tasks", "project_id", "VARCHAR(64) DEFAULT NULL"),
# 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录)
] ]
def _do_ensure(sync_conn) -> None: def _do_ensure(sync_conn) -> None:
......
...@@ -17,6 +17,57 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship ...@@ -17,6 +17,57 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
class PerformanceProject(Base):
"""
性能测试项目数据库模型
用于组织性能测试任务,区分标准版/项目定制场景。
Attributes:
id (str): 项目唯一标识 (proj_xxx)
name (str): 项目名称
description (str): 项目描述
project_type (str): 类型: standard(标准)/custom(项目定制)
icon (str): 项目图标
sort_order (int): 排序序号
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
"""
__tablename__ = "performance_projects"
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="项目唯一标识")
name: Mapped[str] = mapped_column(String(200), default="", comment="项目名称")
description: Mapped[str] = mapped_column(Text, default="", comment="项目描述")
project_type: Mapped[str] = mapped_column(
String(20), default="standard", comment="类型: standard(标准)/custom(项目定制)"
)
icon: Mapped[str] = mapped_column(String(50), default="folder", comment="项目图标")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序序号")
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
def __repr__(self) -> str:
return f"<PerformanceProject(id={self.id}, name={self.name}, type={self.project_type})>"
def to_dict(self) -> dict:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"project_type": self.project_type,
"icon": self.icon,
"sort_order": self.sort_order,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class PerformanceTask(Base): class PerformanceTask(Base):
""" """
性能测试任务数据库模型 性能测试任务数据库模型
...@@ -28,6 +79,7 @@ class PerformanceTask(Base): ...@@ -28,6 +79,7 @@ class PerformanceTask(Base):
name (str): 任务名称 name (str): 任务名称
description (str): 任务描述 description (str): 任务描述
status (str): 状态 (pending/running/completed/failed/cancelled) status (str): 状态 (pending/running/completed/failed/cancelled)
project_id (str): 归属项目ID (nullable)
# 目标配置 # 目标配置
target_url (str): 目标接口完整 URL target_url (str): 目标接口完整 URL
...@@ -84,6 +136,9 @@ class PerformanceTask(Base): ...@@ -84,6 +136,9 @@ class PerformanceTask(Base):
comment="状态: pending/running/completed/failed/cancelled" comment="状态: pending/running/completed/failed/cancelled"
) )
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="错误信息") error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="错误信息")
project_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, default=None, comment="归属项目ID"
)
# 目标配置 # 目标配置
target_url: Mapped[str] = mapped_column(Text, default="", comment="目标接口完整URL") target_url: Mapped[str] = mapped_column(Text, default="", comment="目标接口完整URL")
...@@ -180,6 +235,7 @@ class PerformanceTask(Base): ...@@ -180,6 +235,7 @@ class PerformanceTask(Base):
"description": self.description, "description": self.description,
"status": self.status, "status": self.status,
"error_message": self.error_message, "error_message": self.error_message,
"project_id": self.project_id,
"target_url": self.target_url, "target_url": self.target_url,
"method": self.method, "method": self.method,
"headers": self.headers or {}, "headers": self.headers or {},
......
...@@ -34,6 +34,13 @@ from app.schemas.performance import ( ...@@ -34,6 +34,13 @@ from app.schemas.performance import (
PerformanceReportMetrics, PerformanceReportMetrics,
CurlParseRequest, CurlParseRequest,
CurlParseResponse, CurlParseResponse,
PerformanceProjectCreate,
PerformanceProjectUpdate,
PerformanceProjectResponse,
PerformanceProjectListResponse,
PerformanceProjectStatsResponse,
BatchRunRequest,
BatchRunResponse,
) )
from app.services.performance_service import PerformanceService from app.services.performance_service import PerformanceService
from app.utils.curl_parser import parse_curl from app.utils.curl_parser import parse_curl
...@@ -59,10 +66,25 @@ async def list_tasks( ...@@ -59,10 +66,25 @@ async def list_tasks(
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"), page_size: int = Query(20, ge=1, le=100, description="每页数量"),
status: Optional[str] = Query(None, description="按状态过滤"), status: Optional[str] = Query(None, description="按状态过滤"),
project_id: Optional[str] = Query(None, description="按项目过滤;传 null 查未归属任务,不传返回全部"),
service: PerformanceService = Depends(get_perf_service), service: PerformanceService = Depends(get_perf_service),
): ):
"""获取性能测试任务列表(分页)""" """
items, total = await service.list_tasks(page=page, page_size=page_size, status=status) 获取性能测试任务列表(分页)
- 不传 project_id:返回全部任务(向后兼容)
- project_id=null / empty:返回未归属任何项目的任务
- project_id=xxx:返回指定项目下的任务
"""
_project_id = None
if project_id == "null" or project_id == "":
_project_id = ""
else:
_project_id = project_id
items, total = await service.list_tasks(
page=page, page_size=page_size, status=status, project_id=_project_id
)
return PerformanceTaskListResponse( return PerformanceTaskListResponse(
total=total, total=total,
items=[PerformanceTaskListItem(**t.to_dict()) for t in items], items=[PerformanceTaskListItem(**t.to_dict()) for t in items],
...@@ -327,4 +349,165 @@ async def websocket_monitor(websocket: WebSocket, task_id: str): ...@@ -327,4 +349,165 @@ async def websocket_monitor(websocket: WebSocket, task_id: str):
except Exception as e: except Exception as e:
logger.error(f"WebSocket 异常: {e}") logger.error(f"WebSocket 异常: {e}")
finally: finally:
await manager.disconnect(websocket, ws_group) await manager.disconnect(websocket, ws_group)
\ No newline at end of file
# ==================== 项目 CRUD ====================
@router.get("/projects", response_model=PerformanceProjectListResponse)
async def list_projects(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(100, ge=1, le=200, description="每页数量"),
project_type: Optional[str] = Query(None, description="按类型筛选: standard/custom"),
service: PerformanceService = Depends(get_perf_service),
):
"""
获取性能测试项目列表(分页)
每个项目附带统计信息:任务数、最近执行状态、最近执行时间。
"""
projects, total = await service.list_projects(page=page, page_size=page_size, project_type=project_type)
items = []
for proj in projects:
proj_dict = proj.to_dict()
# 获取项目统计
stats = await service.get_project_stats(proj.id)
proj_dict["task_count"] = stats["task_count"]
proj_dict["last_run_status"] = stats["last_run_status"]
proj_dict["last_run_time"] = stats["last_run_time"]
items.append(PerformanceProjectResponse(**proj_dict))
return PerformanceProjectListResponse(total=total, items=items)
@router.post("/projects", response_model=PerformanceProjectResponse, status_code=201)
async def create_project(
data: PerformanceProjectCreate,
service: PerformanceService = Depends(get_perf_service),
):
"""创建性能测试项目"""
project = await service.create_project(data)
result = project.to_dict()
result["task_count"] = 0
result["last_run_status"] = None
result["last_run_time"] = None
return PerformanceProjectResponse(**result)
@router.get("/projects/{project_id}", response_model=PerformanceProjectResponse)
async def get_project(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""获取性能测试项目详情"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
result = project.to_dict()
stats = await service.get_project_stats(project.id)
result["task_count"] = stats["task_count"]
result["last_run_status"] = stats["last_run_status"]
result["last_run_time"] = stats["last_run_time"]
return PerformanceProjectResponse(**result)
@router.put("/projects/{project_id}", response_model=PerformanceProjectResponse)
async def update_project(
project_id: str,
data: PerformanceProjectUpdate,
service: PerformanceService = Depends(get_perf_service),
):
"""更新性能测试项目"""
project = await service.update_project(project_id, data.model_dump(exclude_unset=True))
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
result = project.to_dict()
stats = await service.get_project_stats(project.id)
result["task_count"] = stats["task_count"]
result["last_run_status"] = stats["last_run_status"]
result["last_run_time"] = stats["last_run_time"]
return PerformanceProjectResponse(**result)
@router.delete("/projects/{project_id}", status_code=204)
async def delete_project(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""删除性能测试项目(不级联删除任务,仅置空 project_id)"""
deleted = await service.delete_project(project_id)
if not deleted:
raise HTTPException(status_code=404, detail="项目不存在")
@router.get("/projects/{project_id}/stats", response_model=PerformanceProjectStatsResponse)
async def get_project_stats(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""获取性能测试项目统计信息"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
stats = await service.get_project_stats(project_id)
return PerformanceProjectStatsResponse(**stats)
# ==================== 批量执行 ====================
@router.post("/tasks/batch-run", response_model=BatchRunResponse)
async def batch_run_tasks(
data: BatchRunRequest,
service: PerformanceService = Depends(get_perf_service),
):
"""
批量执行指定任务列表(串行)
按顺序逐个执行任务,每个任务完成后才执行下一个。
进度通过 WebSocket 推送(perf_batch_progress)。
某个任务失败不影响后续任务执行。
"""
if len(data.task_ids) == 0:
raise HTTPException(status_code=400, detail="任务列表不能为空")
result = await service.batch_run_tasks(
task_ids=data.task_ids,
project_id=data.project_id,
)
return BatchRunResponse(
message=f"批量执行完成: {result['success_count']} 成功, {result['fail_count']} 失败",
batch_id=result["batch_id"],
task_count=result["total"],
)
@router.post("/projects/{project_id}/run-all", response_model=BatchRunResponse)
async def run_project_all(
project_id: str,
service: PerformanceService = Depends(get_perf_service),
):
"""
执行项目下所有任务(串行)
按创建时间顺序执行项目下所有 pending/completed/failed/cancelled 状态的任务。
进度通过 WebSocket 推送(perf_batch_progress)。
"""
project = await service.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
try:
result = await service.run_project_all(project_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return BatchRunResponse(
message=f"项目执行完成: {result['success_count']} 成功, {result['fail_count']} 失败",
batch_id=result["batch_id"],
task_count=result["total"],
)
\ No newline at end of file
...@@ -54,6 +54,7 @@ class PerformanceTaskCreate(BaseModel): ...@@ -54,6 +54,7 @@ class PerformanceTaskCreate(BaseModel):
"""创建性能测试任务""" """创建性能测试任务"""
name: str = Field(..., min_length=1, max_length=200, description="任务名称") name: str = Field(..., min_length=1, max_length=200, description="任务名称")
description: Optional[str] = Field(None, description="任务描述, 留空使用空串") description: Optional[str] = Field(None, description="任务描述, 留空使用空串")
project_id: Optional[str] = Field(None, description="归属项目ID")
# 目标配置 # 目标配置
target_url: str = Field(..., description="目标接口完整URL") target_url: str = Field(..., description="目标接口完整URL")
...@@ -99,6 +100,7 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -99,6 +100,7 @@ class PerformanceTaskUpdate(BaseModel):
"""更新性能测试任务(所有字段可选)""" """更新性能测试任务(所有字段可选)"""
name: Optional[str] = Field(None, min_length=1, max_length=200, description="任务名称") name: Optional[str] = Field(None, min_length=1, max_length=200, description="任务名称")
description: Optional[str] = Field(None, description="任务描述") description: Optional[str] = Field(None, description="任务描述")
project_id: Optional[str] = Field(None, description="归属项目ID")
target_url: Optional[str] = Field(None, description="目标接口完整URL") target_url: Optional[str] = Field(None, description="目标接口完整URL")
method: Optional[str] = Field(None, description="HTTP方法") method: Optional[str] = Field(None, description="HTTP方法")
headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头") headers: Optional[Dict[str, str]] = Field(None, description="自定义请求头")
...@@ -133,6 +135,7 @@ class PerformanceTaskResponse(BaseModel): ...@@ -133,6 +135,7 @@ class PerformanceTaskResponse(BaseModel):
description: str = "" description: str = ""
status: str = "pending" status: str = "pending"
error_message: Optional[str] = None error_message: Optional[str] = None
project_id: Optional[str] = None
target_url: str = "" target_url: str = ""
method: str = "GET" method: str = "GET"
headers: Dict[str, str] = {} headers: Dict[str, str] = {}
...@@ -204,6 +207,7 @@ class PerformanceTaskListItem(BaseModel): ...@@ -204,6 +207,7 @@ class PerformanceTaskListItem(BaseModel):
status: str status: str
mode: str mode: str
target_url: str target_url: str
project_id: Optional[str] = None
total_requests: int = 0 total_requests: int = 0
actual_tps: float = 0.0 actual_tps: float = 0.0
peak_tps: float = 0.0 peak_tps: float = 0.0
...@@ -377,4 +381,84 @@ class PerformanceSnapshotListResponse(BaseModel): ...@@ -377,4 +381,84 @@ class PerformanceSnapshotListResponse(BaseModel):
total: int total: int
items: List[PerformanceSnapshotResponse] items: List[PerformanceSnapshotResponse]
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 性能测试项目 ====================
class PerformanceProjectCreate(BaseModel):
"""创建性能测试项目"""
name: str = Field(..., min_length=1, max_length=200, description="项目名称")
description: str = Field("", description="项目描述")
project_type: str = Field("standard", pattern=r"^(standard|custom)$", description="类型: standard/custom")
icon: str = Field("folder", max_length=50, description="项目图标")
sort_order: int = Field(0, description="排序序号")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectUpdate(BaseModel):
"""更新性能测试项目(所有字段可选)"""
name: Optional[str] = Field(None, min_length=1, max_length=200, description="项目名称")
description: Optional[str] = Field(None, description="项目描述")
project_type: Optional[str] = Field(None, pattern=r"^(standard|custom)$", description="类型: standard/custom")
icon: Optional[str] = Field(None, max_length=50, description="项目图标")
sort_order: Optional[int] = Field(None, description="排序序号")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectResponse(BaseModel):
"""性能测试项目响应"""
id: str
name: str
description: str = ""
project_type: str = "standard"
icon: str = "folder"
sort_order: int = 0
# 统计信息(列表接口附带)
task_count: int = 0
last_run_status: Optional[str] = None
last_run_time: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectListResponse(BaseModel):
"""性能测试项目列表响应"""
total: int
items: List[PerformanceProjectResponse]
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class PerformanceProjectStatsResponse(BaseModel):
"""性能测试项目统计响应"""
project_id: str
task_count: int = 0
status_distribution: Dict[str, int] = Field(default_factory=dict)
last_run_status: Optional[str] = None
last_run_time: Optional[str] = None
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
# ==================== 批量执行 ====================
class BatchRunRequest(BaseModel):
"""批量执行请求"""
task_ids: List[str] = Field(..., min_length=1, description="要执行的任务ID列表")
project_id: Optional[str] = Field(None, description="关联项目ID(可选)")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class BatchRunResponse(BaseModel):
"""批量执行响应"""
message: str
batch_id: str
task_count: int
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
\ No newline at end of file
...@@ -1051,28 +1051,76 @@ class DeviceSimService: ...@@ -1051,28 +1051,76 @@ class DeviceSimService:
# ==================== 批量操作 ==================== # ==================== 批量操作 ====================
async def batch_start_simulators(self, device_ids: List[str]) -> dict: async def batch_start_simulators(self, device_ids: List[str], max_concurrency: int = 50) -> dict:
""" """
批量启动模拟设备 批量启动模拟设备(线程池并行执行,提高大批量启动效率)
使用 run_in_executor + ThreadPoolExecutor 实现真正的并行启动,
每个设备在独立线程中运行,拥有独立的 asyncio 事件循环和数据库会话。
避免 `asyncio.gather` 在同步 HTTP 调用(token API)时阻塞事件循环的问题。
Args: Args:
device_ids: 设备 ID 列表 device_ids: 设备 ID 列表
max_concurrency: 最大并行数(默认 50,防止 Broker/Token API 过载)
Returns: Returns:
dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str} dict: {"success_count": int, "failed_count": int, "failed_ids": list, "message": str}
""" """
success_count = 0 from concurrent.futures import ThreadPoolExecutor
failed_ids = [] from app.database import async_session_maker
for device_id in device_ids: if not device_ids:
return {
"success_count": 0,
"failed_count": 0,
"failed_ids": [],
"message": "没有选择要启动的设备",
}
# 去重,避免同一设备重复启动
unique_ids = list(dict.fromkeys(device_ids))
def _start_one_sync(device_id: str) -> bool:
"""
在独立线程中启动单个设备(同步包装器)
每个线程创建独立的 asyncio 事件循环和数据库会话,
实现真正的并行执行,避免同步 HTTP 调用阻塞事件循环。
"""
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try: try:
result = await self.start_simulator(device_id) async def _run():
if result: async with async_session_maker() as session:
success_count += 1 svc = DeviceSimService(session)
else: result = await svc.start_simulator(device_id)
failed_ids.append(device_id) await session.commit()
return result
return new_loop.run_until_complete(_run())
except Exception as e: except Exception as e:
logger.warning(f"批量启动设备失败: {device_id}, error={e}") logger.warning(f"批量启动设备失败: {device_id}, error={e}")
return False
finally:
new_loop.close()
loop = asyncio.get_event_loop()
semaphore = asyncio.Semaphore(max_concurrency)
# 使用线程池 + 信号量控制并发
with ThreadPoolExecutor(max_workers=max_concurrency, thread_name_prefix="sim_start") as executor:
async def _start_one(device_id: str) -> Tuple[str, bool]:
async with semaphore:
result = await loop.run_in_executor(executor, _start_one_sync, device_id)
return device_id, result
results = await asyncio.gather(*[_start_one(did) for did in unique_ids])
success_count = 0
failed_ids = []
for device_id, success in results:
if success:
success_count += 1
else:
failed_ids.append(device_id) failed_ids.append(device_id)
return { return {
......
...@@ -76,18 +76,12 @@ class MqttConnection: ...@@ -76,18 +76,12 @@ class MqttConnection:
with self._lock: with self._lock:
self.connected = False self.connected = False
if rc != 0: if rc != 0:
# 非主动断开,尝试重连 # 非主动断开,由 paho-mqtt 内置 reconnect_delay_set 自动重连
self.reconnect_attempts += 1 # 注意:不在回调中手动调用 client.reconnect(),避免与内置重连机制冲突
delay = min(2 ** self.reconnect_attempts, 60) # 指数退避,最大 60 秒
logger.warning( logger.warning(
f"MQTT 意外断开: env_config_id={self.env_config_id}, " f"MQTT 意外断开: env_config_id={self.env_config_id}, rc={rc}, "
f"重连尝试={self.reconnect_attempts}, 等待={delay}s" f"等待自动重连..."
) )
time.sleep(delay)
try:
client.reconnect()
except Exception as e:
logger.error(f"MQTT 重连失败: {e}")
else: else:
logger.info(f"MQTT 主动断开: env_config_id={self.env_config_id}") logger.info(f"MQTT 主动断开: env_config_id={self.env_config_id}")
......
...@@ -20,10 +20,18 @@ from typing import Any, Callable, Dict, List, Optional, Tuple ...@@ -20,10 +20,18 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.performance import PerformanceTask, PerformanceSnapshot from app.models.performance import (
PerformanceTask,
PerformanceSnapshot,
PerformanceProject,
)
from app.models.performance_output import PerformanceTaskOutput from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset from app.models.api_preset import ApiPreset
from app.schemas.performance import PerformanceTaskCreate from app.schemas.performance import (
PerformanceTaskCreate,
PerformanceProjectCreate,
PerformanceProjectUpdate,
)
from app.executors.performance_executor import PerformanceExecutor from app.executors.performance_executor import PerformanceExecutor
from app.websocket.manager import manager from app.websocket.manager import manager
from app.utils.id_generator import generate_id from app.utils.id_generator import generate_id
...@@ -79,6 +87,7 @@ class PerformanceService: ...@@ -79,6 +87,7 @@ class PerformanceService:
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
status: Optional[str] = None, status: Optional[str] = None,
project_id: Optional[str] = None,
) -> Tuple[List[PerformanceTask], int]: ) -> Tuple[List[PerformanceTask], int]:
""" """
获取任务列表(分页) 获取任务列表(分页)
...@@ -87,6 +96,7 @@ class PerformanceService: ...@@ -87,6 +96,7 @@ class PerformanceService:
page: 页码 page: 页码
page_size: 每页数量 page_size: 每页数量
status: 按状态过滤 status: 按状态过滤
project_id: 按项目过滤;空字符串查未归属,None 查全部
Returns: Returns:
tuple: (任务列表, 总数) tuple: (任务列表, 总数)
...@@ -98,6 +108,15 @@ class PerformanceService: ...@@ -98,6 +108,15 @@ class PerformanceService:
query = query.where(PerformanceTask.status == status) query = query.where(PerformanceTask.status == status)
count_query = count_query.where(PerformanceTask.status == status) count_query = count_query.where(PerformanceTask.status == status)
if project_id is not None:
if project_id == "":
# 查询未归属项目的任务
query = query.where(PerformanceTask.project_id.is_(None))
count_query = count_query.where(PerformanceTask.project_id.is_(None))
else:
query = query.where(PerformanceTask.project_id == project_id)
count_query = count_query.where(PerformanceTask.project_id == project_id)
total = (await self.db.execute(count_query)).scalar() or 0 total = (await self.db.execute(count_query)).scalar() or 0
query = ( query = (
...@@ -170,11 +189,8 @@ class PerformanceService: ...@@ -170,11 +189,8 @@ class PerformanceService:
""" """
从接口预设继承字段(任务显式传入的值覆盖预设默认值) 从接口预设继承字段(任务显式传入的值覆盖预设默认值)
输入的 data 为 exclude_unset 后的显式字段字典,
仅填充未显式传入的接口配置字段。
Args: Args:
data: 任务显式传入的字段(exclude_unset 结果 data: 任务创建数据(exclude_unset 后的显式字段,未传入字段以默认值补齐
preset: 接口预设对象 preset: 接口预设对象
Returns: Returns:
...@@ -206,47 +222,6 @@ class PerformanceService: ...@@ -206,47 +222,6 @@ class PerformanceService:
data["login_password"] = preset.login_password data["login_password"] = preset.login_password
return data return data
@staticmethod
def _inherit_from_preset(data: Dict[str, Any], preset: ApiPreset) -> Dict[str, Any]:
"""
从接口预设继承字段(任务显式传入的值覆盖预设默认值)
Args:
data: 任务创建数据(model_dump 后含全部字段)
preset: 接口预设对象
Returns:
dict: 合并后的任务数据
"""
# 预设字段作为默认值,任务显式传入的字段覆盖
data["method"] = data.get("method") or preset.method
data["target_url"] = data.get("target_url") or preset.target_url
data["headers"] = data.get("headers") or (preset.headers or {})
data["body"] = data["body"] if data.get("body") is not None else preset.body
data["body_template"] = data.get("body_template") or preset.body_template
data["capture_rules"] = (
data.get("capture_rules")
if data.get("capture_rules") is not None
else (preset.capture_rules or [])
)
data["auth_required"] = (
data.get("auth_required")
if data.get("auth_required") is not None
else preset.auth_required
)
data["sign_request"] = (
data.get("sign_request")
if data.get("sign_request") is not None
else preset.sign_request
)
data["account_key"] = data.get("account_key") or preset.account_key
# 登录凭据:任务显式传入的优先,否则用预设值(预设留空则不覆盖)
if not data.get("login_username") and preset.login_username:
data["login_username"] = preset.login_username
if not data.get("login_password") and preset.login_password:
data["login_password"] = preset.login_password
return data
async def update_task( async def update_task(
self, task_id: str, data: Dict[str, Any] self, task_id: str, data: Dict[str, Any]
) -> Optional[PerformanceTask]: ) -> Optional[PerformanceTask]:
...@@ -852,4 +827,321 @@ class PerformanceService: ...@@ -852,4 +827,321 @@ class PerformanceService:
) )
) )
output = result.scalar_one_or_none() output = result.scalar_one_or_none()
return list(output.value) if output and output.value else [] return list(output.value) if output and output.value else []
\ No newline at end of file
# ==================== 项目 CRUD ====================
async def list_projects(
self,
page: int = 1,
page_size: int = 100,
project_type: Optional[str] = None,
) -> Tuple[List[PerformanceProject], int]:
"""
获取项目列表
Args:
page: 页码
page_size: 每页数量
project_type: 按类型筛选 (standard/custom)
Returns:
tuple: (项目列表, 总数)
"""
query = select(PerformanceProject)
count_query = select(func.count()).select_from(PerformanceProject)
if project_type:
query = query.where(PerformanceProject.project_type == project_type)
count_query = count_query.where(PerformanceProject.project_type == project_type)
total = (await self.db.execute(count_query)).scalar() or 0
query = (
query.order_by(PerformanceProject.sort_order.asc(), desc(PerformanceProject.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await self.db.execute(query)
projects = list(result.scalars().all())
return projects, total
async def create_project(self, data: PerformanceProjectCreate) -> PerformanceProject:
"""
创建项目
Args:
data: 项目创建数据
Returns:
PerformanceProject: 创建的项目
"""
payload = data.model_dump()
project = PerformanceProject(
id=generate_id("proj"),
**payload,
)
self.db.add(project)
await self.db.commit()
await self.db.refresh(project)
logger.info(f"创建性能测试项目: {project.id} - {project.name}")
return project
async def get_project(self, project_id: str) -> Optional[PerformanceProject]:
"""
获取项目详情
Args:
project_id: 项目ID
Returns:
PerformanceProject: 项目对象,不存在返回 None
"""
return await self.db.get(PerformanceProject, project_id)
async def update_project(
self, project_id: str, data: Dict[str, Any]
) -> Optional[PerformanceProject]:
"""
更新项目
Args:
project_id: 项目ID
data: 更新数据
Returns:
PerformanceProject: 更新后的项目,不存在返回 None
"""
project = await self.get_project(project_id)
if not project:
return None
for key, value in data.items():
if value is not None and hasattr(project, key):
setattr(project, key, value)
await self.db.commit()
await self.db.refresh(project)
return project
async def delete_project(self, project_id: str) -> bool:
"""
删除项目(不级联删除任务,仅置空 project_id)
Args:
project_id: 项目ID
Returns:
bool: 是否成功删除
"""
project = await self.get_project(project_id)
if not project:
return False
# 解除关联:将该项目下所有任务的 project_id 置为 NULL
stmt = (
select(PerformanceTask)
.where(PerformanceTask.project_id == project_id)
)
result = await self.db.execute(stmt)
tasks = list(result.scalars().all())
for task in tasks:
task.project_id = None
await self.db.delete(project)
await self.db.commit()
logger.info(f"删除性能测试项目: {project_id}")
return True
async def get_project_stats(self, project_id: str) -> Dict[str, Any]:
"""
获取项目统计信息
Args:
project_id: 项目ID
Returns:
dict: 统计信息(任务数、状态分布等)
"""
# 查询该项目下的所有任务
result = await self.db.execute(
select(PerformanceTask)
.where(PerformanceTask.project_id == project_id)
)
tasks = list(result.scalars().all())
task_count = len(tasks)
status_distribution = {}
last_run_status = None
last_run_time = None
for task in tasks:
status_distribution[task.status] = status_distribution.get(task.status, 0) + 1
# 最近执行的任务(start_time 非空且最近)
if task.start_time and (
last_run_time is None or task.start_time > last_run_time
):
last_run_time = task.start_time
last_run_status = task.status
return {
"project_id": project_id,
"task_count": task_count,
"status_distribution": status_distribution,
"last_run_status": last_run_status,
"last_run_time": last_run_time.isoformat() if last_run_time else None,
}
# ==================== 批量执行 ====================
async def batch_run_tasks(
self,
task_ids: List[str],
project_id: Optional[str] = None,
progress_callback: Optional[Callable] = None,
) -> Dict[str, Any]:
"""
批量执行多个任务(串行,一个完成后再执行下一个)
Args:
task_ids: 任务ID列表
project_id: 关联项目ID(可选)
progress_callback: 进度回调
Returns:
dict: 批量执行结果汇总
"""
batch_id = generate_id("batch")
total = len(task_ids)
success_count = 0
fail_count = 0
results = []
ws_group = "perf_batch"
# 广播批量执行开始
await manager.broadcast(ws_group, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
"current_index": 0,
"total_count": total,
"current_task_id": None,
"current_task_name": None,
"current_status": "running",
"success_count": 0,
"fail_count": 0,
})
for idx, task_id in enumerate(task_ids):
current_index = idx + 1
task = None
try:
# 获取任务名称用于进度展示
task = await self.get_task(task_id)
task_name = task.name if task else task_id
# 广播当前任务开始
await manager.broadcast(ws_group, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
"current_index": current_index,
"total_count": total,
"current_task_id": task_id,
"current_task_name": task_name,
"current_status": "running",
"success_count": success_count,
"fail_count": fail_count,
})
# 执行单个任务(串行,等待完成)
result = await self.run_task(task_id)
if result.get("status") == "completed":
success_count += 1
else:
fail_count += 1
results.append({
"task_id": task_id,
"status": result.get("status", "failed"),
})
except Exception as e:
fail_count += 1
logger.error(f"批量执行任务失败 [{task_id}]: {e}")
results.append({
"task_id": task_id,
"status": "failed",
"error": str(e),
})
# 广播当前任务完成
await manager.broadcast(ws_group, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
"current_index": current_index,
"total_count": total,
"current_task_id": task_id,
"current_task_name": task.name if task else task_id,
"current_status": "completed",
"success_count": success_count,
"fail_count": fail_count,
})
# 广播批量执行完成
final_status = "completed" if fail_count == 0 else "partially_failed"
await manager.broadcast(ws_group, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
"current_index": total,
"total_count": total,
"current_task_id": None,
"current_task_name": None,
"current_status": final_status,
"success_count": success_count,
"fail_count": fail_count,
})
return {
"batch_id": batch_id,
"total": total,
"success_count": success_count,
"fail_count": fail_count,
"status": final_status,
"results": results,
}
async def run_project_all(
self,
project_id: str,
) -> Dict[str, Any]:
"""
执行项目下所有任务
Args:
project_id: 项目ID
Returns:
dict: 批量执行结果
"""
# 查询项目下所有可执行状态的任务
result = await self.db.execute(
select(PerformanceTask)
.where(
PerformanceTask.project_id == project_id,
PerformanceTask.status.in_(["pending", "completed", "failed", "cancelled"]),
)
.order_by(PerformanceTask.created_at.asc())
)
tasks = list(result.scalars().all())
if not tasks:
raise ValueError("项目下没有可执行的任务")
task_ids = [t.id for t in tasks]
return await self.batch_run_tasks(task_ids, project_id=project_id)
\ No newline at end of file
...@@ -84,6 +84,7 @@ ...@@ -84,6 +84,7 @@
<el-icon><Odometer /></el-icon> <el-icon><Odometer /></el-icon>
<span>性能测试</span> <span>性能测试</span>
</template> </template>
<el-menu-item index="/performance/projects">项目管理</el-menu-item>
<el-menu-item index="/performance/tasks">任务管理</el-menu-item> <el-menu-item index="/performance/tasks">任务管理</el-menu-item>
<el-menu-item index="/performance/monitor">执行监控</el-menu-item> <el-menu-item index="/performance/monitor">执行监控</el-menu-item>
<el-menu-item index="/performance/report">报告查看</el-menu-item> <el-menu-item index="/performance/report">报告查看</el-menu-item>
...@@ -280,6 +281,7 @@ const DEPLOY_LABELS: Record<string, string> = { ...@@ -280,6 +281,7 @@ const DEPLOY_LABELS: Record<string, string> = {
/** 性能测试子页面标签映射 */ /** 性能测试子页面标签映射 */
const PERFORMANCE_LABELS: Record<string, string> = { const PERFORMANCE_LABELS: Record<string, string> = {
projects: '项目管理',
tasks: '任务管理', tasks: '任务管理',
monitor: '执行监控', monitor: '执行监控',
report: '报告查看', report: '报告查看',
......
...@@ -16,6 +16,13 @@ import type { ...@@ -16,6 +16,13 @@ import type {
PerformanceSnapshotListResponse, PerformanceSnapshotListResponse,
PerformanceReportResponse, PerformanceReportResponse,
CurlParseResult, CurlParseResult,
PerformanceProjectCreate,
PerformanceProjectUpdate,
PerformanceProject,
PerformanceProjectListResponse,
PerformanceProjectStats,
BatchRunRequest,
BatchRunResponse,
} from '@/types/performance' } from '@/types/performance'
const BASE = '/api/performance' const BASE = '/api/performance'
...@@ -27,6 +34,8 @@ export function listTasks(params?: { ...@@ -27,6 +34,8 @@ export function listTasks(params?: {
page?: number page?: number
pageSize?: number pageSize?: number
status?: string status?: string
/** 按项目过滤;传 null 查未归属任务,不传返回全部 */
projectId?: string | null
}): Promise<PerformanceTaskListResponse> { }): Promise<PerformanceTaskListResponse> {
return request.get(`${BASE}/tasks`, { params }) return request.get(`${BASE}/tasks`, { params })
} }
...@@ -158,4 +167,52 @@ export function deletePreset(id: string): Promise<void> { ...@@ -158,4 +167,52 @@ export function deletePreset(id: string): Promise<void> {
/** 解析 curl 命令,返回结构化压测配置 */ /** 解析 curl 命令,返回结构化压测配置 */
export function parseCurl(curlCommand: string): Promise<CurlParseResult> { export function parseCurl(curlCommand: string): Promise<CurlParseResult> {
return request.post(`${BASE}/parse-curl`, { curlCommand }) return request.post(`${BASE}/parse-curl`, { curlCommand })
}
// ==================== 项目 CRUD ====================
/** 获取项目列表 */
export function listProjects(params?: {
page?: number
pageSize?: number
projectType?: string
}): Promise<PerformanceProjectListResponse> {
return request.get(`${BASE}/projects`, { params })
}
/** 获取项目详情 */
export function getProject(id: string): Promise<PerformanceProject> {
return request.get(`${BASE}/projects/${id}`)
}
/** 创建项目 */
export function createProject(data: PerformanceProjectCreate): Promise<PerformanceProject> {
return request.post(`${BASE}/projects`, data)
}
/** 更新项目 */
export function updateProject(id: string, data: PerformanceProjectUpdate): Promise<PerformanceProject> {
return request.put(`${BASE}/projects/${id}`, data)
}
/** 删除项目 */
export function deleteProject(id: string): Promise<void> {
return request.delete(`${BASE}/projects/${id}`)
}
/** 获取项目统计 */
export function getProjectStats(id: string): Promise<PerformanceProjectStats> {
return request.get(`${BASE}/projects/${id}/stats`)
}
// ==================== 批量执行 ====================
/** 批量执行指定任务 */
export function batchRunTasks(data: BatchRunRequest): Promise<BatchRunResponse> {
return request.post(`${BASE}/tasks/batch-run`, data)
}
/** 执行项目下所有任务 */
export function runProjectAll(projectId: string): Promise<BatchRunResponse> {
return request.post(`${BASE}/projects/${projectId}/run-all`)
} }
\ No newline at end of file
...@@ -163,6 +163,18 @@ const routes: RouteRecordRaw[] = [ ...@@ -163,6 +163,18 @@ const routes: RouteRecordRaw[] = [
redirect: '/performance/tasks', redirect: '/performance/tasks',
meta: { title: '性能测试' }, meta: { title: '性能测试' },
children: [ children: [
{
path: 'projects',
name: 'PerfProjects',
component: () => import('@/views/performance/Projects.vue'),
meta: { title: '项目管理' }
},
{
path: 'projects/:id',
name: 'PerfProjectDetail',
component: () => import('@/views/performance/ProjectDetail.vue'),
meta: { title: '项目详情' }
},
{ {
path: 'tasks', path: 'tasks',
name: 'PerfTasks', name: 'PerfTasks',
......
...@@ -33,6 +33,9 @@ export interface UniqueFieldRule { ...@@ -33,6 +33,9 @@ export interface UniqueFieldRule {
template?: string | null template?: string | null
} }
/** 项目类型 */
export type PerfProjectType = 'standard' | 'custom'
/** 性能测试任务 */ /** 性能测试任务 */
export interface PerformanceTask { export interface PerformanceTask {
id: string id: string
...@@ -40,6 +43,7 @@ export interface PerformanceTask { ...@@ -40,6 +43,7 @@ export interface PerformanceTask {
description: string | null description: string | null
status: PerfStatus status: PerfStatus
errorMessage: string | null errorMessage: string | null
projectId: string | null
targetUrl: string targetUrl: string
method: string method: string
headers: Record<string, string> | null headers: Record<string, string> | null
...@@ -100,6 +104,7 @@ export interface PerformanceTask { ...@@ -100,6 +104,7 @@ export interface PerformanceTask {
export interface PerformanceTaskCreate { export interface PerformanceTaskCreate {
name: string name: string
description?: string | null description?: string | null
projectId?: string | null
targetUrl: string targetUrl: string
method?: string method?: string
headers?: Record<string, string> | null headers?: Record<string, string> | null
...@@ -125,6 +130,7 @@ export interface PerformanceTaskCreate { ...@@ -125,6 +130,7 @@ export interface PerformanceTaskCreate {
export interface PerformanceTaskUpdate { export interface PerformanceTaskUpdate {
name?: string name?: string
description?: string | null description?: string | null
projectId?: string | null
targetUrl?: string targetUrl?: string
method?: string method?: string
headers?: Record<string, string> | null headers?: Record<string, string> | null
...@@ -152,6 +158,7 @@ export interface PerformanceTaskListItem { ...@@ -152,6 +158,7 @@ export interface PerformanceTaskListItem {
name: string name: string
description: string | null description: string | null
status: PerfStatus status: PerfStatus
projectId: string | null
targetUrl: string targetUrl: string
method: string method: string
mode: PerfMode mode: PerfMode
...@@ -385,4 +392,83 @@ export interface CurlParseResult { ...@@ -385,4 +392,83 @@ export interface CurlParseResult {
signRequest: boolean signRequest: boolean
cookie: string | null cookie: string | null
errors: string[] errors: string[]
}
// ==================== 性能测试项目 ====================
/** 性能测试项目 */
export interface PerformanceProject {
id: string
name: string
description: string
projectType: PerfProjectType
icon: string
sortOrder: number
taskCount: number
lastRunStatus: string | null
lastRunTime: string | null
createdAt: string | null
updatedAt: string | null
}
/** 创建项目请求 */
export interface PerformanceProjectCreate {
name: string
description?: string
projectType?: PerfProjectType
icon?: string
sortOrder?: number
}
/** 更新项目请求 */
export interface PerformanceProjectUpdate {
name?: string
description?: string
projectType?: PerfProjectType
icon?: string
sortOrder?: number
}
/** 项目列表响应 */
export interface PerformanceProjectListResponse {
total: number
items: PerformanceProject[]
}
/** 项目统计 */
export interface PerformanceProjectStats {
projectId: string
taskCount: number
statusDistribution: Record<string, number>
lastRunStatus: string | null
lastRunTime: string | null
}
// ==================== 批量执行 ====================
/** 批量执行请求 */
export interface BatchRunRequest {
taskIds: string[]
projectId?: string | null
}
/** 批量执行响应 */
export interface BatchRunResponse {
message: string
batchId: string
taskCount: number
}
/** WebSocket 批量执行进度消息 */
export interface PerfBatchProgress {
type: 'perf_batch_progress'
batchId: string
projectId: string | null
currentIndex: number
totalCount: number
currentTaskId: string
currentTaskName: string
currentStatus: 'running' | 'completed' | 'failed'
successCount: number
failCount: number
} }
\ No newline at end of file
<!--
组件名称:ProjectDetail.vue
组件描述:性能测试项目详情页(任务列表 + 复选框批量执行 + WebSocket 进度)
@author czj
@date 2026-08-20
-->
<template>
<div class="project-detail-page">
<!-- 返回按钮 + 项目信息头部 -->
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" text>
<el-icon><ArrowLeft /></el-icon>返回
</el-button>
<h2 class="project-title">{{ project?.name || '项目加载中...' }}</h2>
<el-tag v-if="project" size="small" :type="project.projectType === 'standard' ? 'info' : 'warning'">
{{ project.projectType === 'standard' ? '标准版' : '项目定制' }}
</el-tag>
</div>
<div class="header-right">
<el-button type="primary" :loading="runningAll" @click="handleRunAll">
<el-icon><VideoPlay /></el-icon>执行全部
</el-button>
<el-button v-if="selectedIds.length > 0" type="warning" :loading="runningBatch" @click="handleBatchRun">
<el-icon><VideoPlay /></el-icon>批量执行({{ selectedIds.length }}
</el-button>
<el-button @click="loadTasks">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<!-- 项目描述 -->
<div v-if="project?.description" class="project-desc-bar">
{{ project.description }}
</div>
<!-- 批量执行进度条 -->
<el-card v-if="batchProgressVisible" shadow="never" class="batch-progress-card">
<template #header>
<div class="batch-progress-header">
<span>批量执行进度</span>
<el-button text size="small" @click="batchProgressVisible = false">关闭</el-button>
</div>
</template>
<div class="batch-progress-body">
<div class="batch-progress-info">
<span>正在执行:{{ batchCurrentTaskName }}</span>
<span class="batch-progress-count">
成功 {{ batchSuccessCount }} / 失败 {{ batchFailCount }}
</span>
</div>
<el-progress
:percentage="batchProgressPercent"
:status="batchProgressStatus"
:stroke-width="16"
:text-inside="true"
/>
<div class="batch-progress-text">
第 {{ batchCurrentIndex }} / {{ batchTotalCount }} 个任务
</div>
</div>
</el-card>
<!-- 任务列表 -->
<el-card shadow="never">
<div class="toolbar">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 140px" @change="loadTasks">
<el-option label="全部" value="" />
<el-option label="等待中" value="pending" />
<el-option label="运行中" value="running" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</div>
<el-table
ref="tableRef"
:data="tasks"
v-loading="loading"
stripe
style="width: 100%"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="name" label="任务名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="targetUrl" label="目标 URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column label="模式" width="90">
<template #default="{ row }">
<el-tag size="small">{{ modeLabel(row.mode) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="并发" width="70" prop="concurrency" />
<el-table-column label="时长" width="80">
<template #default="{ row }">{{ row.duration }}s</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="TPS" width="90">
<template #default="{ row }">
{{ row.actualTps != null ? row.actualTps.toFixed(1) : '-' }}
</template>
</el-table-column>
<el-table-column label="错误率" width="90">
<template #default="{ row }">
<span :style="{ color: (row.errorRate || 0) > 5 ? '#f56c6c' : '#67c23a' }">
{{ row.errorRate != null ? (row.errorRate * 100).toFixed(1) + '%' : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'pending' || row.status === 'completed' || row.status === 'failed' || row.status === 'cancelled'"
link type="primary" size="small" :loading="runningId === row.id" @click="handleRun(row)"
>
执行
</el-button>
<el-button v-if="row.status === 'running'" link type="warning" size="small" @click="handleStop(row)">
停止
</el-button>
<el-button
v-if="row.status === 'running' || row.status === 'completed'"
link type="primary" size="small" @click="router.push('/performance/monitor?taskId=' + row.id)"
>
监控
</el-button>
<el-button
v-if="row.status === 'completed' || row.status === 'failed'"
link type="primary" size="small" @click="router.push('/performance/report?taskId=' + row.id)"
>
报告
</el-button>
<el-popconfirm title="确定删除此任务?" @confirm="handleDelete(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@change="loadTasks"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* 性能测试项目详情页
*
* 功能:
* 1. 项目信息展示 + 返回按钮
* 2. 任务列表(带复选框,按项目过滤)
* 3. 执行全部(runProjectAll)
* 4. 批量执行选中任务(batchRunTasks)
* 5. WebSocket 批量执行进度条
* 6. 单任务执行/停止/删除/跳转监控报告
*/
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, VideoPlay, Refresh } from '@element-plus/icons-vue'
import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks } from '@/api/performance'
import type { PerformanceProject, PerformanceTaskListItem } from '@/types/performance'
const router = useRouter()
const route = useRoute()
// ==================== 响应式数据 ====================
const projectId = computed(() => route.params.id as string)
const loading = ref(false)
const project = ref<PerformanceProject | null>(null)
const tasks = ref<PerformanceTaskListItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const statusFilter = ref('')
const runningId = ref('')
const runningAll = ref(false)
const runningBatch = ref(false)
// 复选框选中
const selectedIds = ref<string[]>([])
const tableRef = ref()
// 批量执行进度
const batchProgressVisible = ref(false)
const batchCurrentIndex = ref(0)
const batchTotalCount = ref(0)
const batchCurrentTaskName = ref('')
const batchSuccessCount = ref(0)
const batchFailCount = ref(0)
const batchProgressPercent = computed(() => {
if (batchTotalCount.value === 0) return 0
return Math.round((batchCurrentIndex.value / batchTotalCount.value) * 100)
})
const batchProgressStatus = computed(() => {
if (batchFailCount.value > 0) return 'exception'
if (batchProgressPercent.value >= 100) return 'success'
return ''
})
// WebSocket 实例
let ws: WebSocket | null = null
// ==================== 方法定义 ====================
/** 返回项目管理页 */
function goBack() {
router.push('/performance/projects')
}
/** 加载项目信息 */
async function loadProject() {
if (!projectId.value) return
try {
project.value = await getProject(projectId.value)
} catch (e: any) {
ElMessage.error('加载项目信息失败: ' + (e.response?.data?.detail || e.message || ''))
}
}
/** 加载任务列表(按项目过滤) */
async function loadTasks() {
if (!projectId.value) return
loading.value = true
try {
const params: any = { page: page.value, pageSize: pageSize.value, projectId: projectId.value }
if (statusFilter.value) params.status = statusFilter.value
const res = await listTasks(params)
tasks.value = res.items
total.value = res.total
} catch (e: any) {
ElMessage.error('加载任务列表失败: ' + (e.message || ''))
} finally {
loading.value = false
}
}
/** 复选框选中变化 */
function onSelectionChange(selection: PerformanceTaskListItem[]) {
selectedIds.value = selection.map(t => t.id)
}
/** 执行单个任务 */
async function handleRun(task: PerformanceTaskListItem) {
runningId.value = task.id
try {
const res = await runTask(task.id)
ElMessage.success(res.message || '任务已开始执行')
await loadTasks()
} catch (e: any) {
ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
runningId.value = ''
}
}
/** 停止单个任务 */
async function handleStop(task: PerformanceTaskListItem) {
try {
const res = await stopTask(task.id)
ElMessage.success(res.message || '已发送停止信号')
await loadTasks()
} catch (e: any) {
ElMessage.error('停止失败: ' + (e.response?.data?.detail || e.message || ''))
}
}
/** 删除单个任务 */
async function handleDelete(task: PerformanceTaskListItem) {
try {
await deleteTask(task.id)
ElMessage.success('任务已删除')
await loadTasks()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.response?.data?.detail || e.message || ''))
}
}
/** 执行全部 */
async function handleRunAll() {
const confirmed = await ElMessageBox.confirm(
`确定要执行项目"${project.value?.name}"下的全部任务吗?`,
'确认执行',
{ type: 'warning' }
).catch(() => null)
if (confirmed === null) return
runningAll.value = true
showBatchProgress(tasks.value.length)
try {
const res = await runProjectAll(projectId.value)
ElMessage.success(res.message || '项目已开始执行')
} catch (e: any) {
ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
runningAll.value = false
}
}
/** 批量执行选中任务 */
async function handleBatchRun() {
if (selectedIds.value.length === 0) {
ElMessage.warning('请至少选择一个任务')
return
}
const confirmed = await ElMessageBox.confirm(
`确定要批量执行选中的 ${selectedIds.value.length} 个任务吗?`,
'确认执行',
{ type: 'warning' }
).catch(() => null)
if (confirmed === null) return
runningBatch.value = true
showBatchProgress(selectedIds.value.length)
try {
const res = await batchRunTasks({ taskIds: selectedIds.value, projectId: projectId.value })
ElMessage.success(res.message || '批量执行完成')
selectedIds.value = []
tableRef.value?.clearSelection()
await loadTasks()
} catch (e: any) {
ElMessage.error('批量执行失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
runningBatch.value = false
}
}
/** 显示批量执行进度条连接 WebSocket */
function showBatchProgress(totalCount: number) {
batchProgressVisible.value = true
batchCurrentIndex.value = 0
batchTotalCount.value = totalCount
batchCurrentTaskName.value = '准备中...'
batchSuccessCount.value = 0
batchFailCount.value = 0
// 连接 WebSocket 监听批量进度
connectBatchWs()
}
/** 连接 WebSocket 监听批量执行进度 */
function connectBatchWs() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/performance/ws/batch-progress`
ws = new WebSocket(wsUrl)
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'perf_batch_progress') {
batchCurrentIndex.value = msg.currentIndex
batchTotalCount.value = msg.totalCount
batchCurrentTaskName.value = msg.currentTaskName || ''
batchSuccessCount.value = msg.successCount || 0
batchFailCount.value = msg.failCount || 0
}
} catch {
// 忽略解析错误
}
}
ws.onerror = () => {
// WebSocket 连接失败不影响主流程
}
ws.onclose = () => {
ws = null
}
}
/** 状态标签类型 */
function statusType(status: string) {
const map: Record<string, string> = { completed: 'success', running: 'primary', pending: 'info', failed: 'danger', cancelled: 'warning' }
return map[status] || 'info'
}
/** 状态标签文字 */
function statusLabel(status: string) {
const map: Record<string, string> = { completed: '已完成', running: '运行中', pending: '等待中', failed: '失败', cancelled: '已取消' }
return map[status] || status
}
/** 模式标签 */
function modeLabel(mode: string) {
const map: Record<string, string> = { concurrent: '并发', qps: 'QPS', step: '阶梯' }
return map[mode] || mode
}
/** 格式化时间 */
function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || ''
}
// ==================== 生命周期 ====================
onMounted(() => {
loadProject()
loadTasks()
})
onUnmounted(() => {
// 关闭 WebSocket 连接
if (ws) {
ws.close()
ws = null
}
})
</script>
<style scoped>
.project-detail-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.project-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.header-right {
display: flex;
gap: 12px;
}
}
.project-desc-bar {
background: #f0f5ff;
border: 1px solid #d6e4ff;
border-radius: 4px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 13px;
color: #606266;
line-height: 1.6;
}
.batch-progress-card {
margin-bottom: 16px;
background: #fafafa;
.batch-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 14px;
}
.batch-progress-body {
.batch-progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
.batch-progress-count {
color: #909399;
}
}
.batch-progress-text {
margin-top: 6px;
font-size: 12px;
color: #909399;
text-align: right;
}
}
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
\ No newline at end of file
<!--
组件名称:Projects.vue
组件描述:性能测试项目管理页面(卡片网格 + 类型筛选 + 一键执行 + 项目CRUD)
@author czj
@date 2026-08-20
-->
<template>
<div class="projects-page">
<!-- 操作栏 -->
<div class="page-header">
<el-tabs v-model="projectType" @tab-change="loadProjects">
<el-tab-pane label="标准版" name="standard" />
<el-tab-pane label="项目定制" name="custom" />
</el-tabs>
<div class="header-actions">
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新建项目
</el-button>
<el-button @click="loadProjects">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<!-- 项目卡片网格 -->
<el-row :gutter="20" v-loading="loading">
<el-col :span="6" v-for="project in projects" :key="project.id">
<el-card class="project-card" shadow="hover" @click="enterProject(project)">
<div class="project-icon">
<el-icon :size="32"><component :is="iconOf(project.icon)" /></el-icon>
<el-tag size="small" :type="project.projectType === 'standard' ? 'info' : 'warning'" class="project-type-tag">
{{ project.projectType === 'standard' ? '标准版' : '项目定制' }}
</el-tag>
</div>
<div class="project-info">
<h3 class="project-name">{{ project.name }}</h3>
<p class="project-desc">{{ project.description || '暂无描述' }}</p>
<div class="project-stats">
<span class="stat-item">
<el-icon><Document /></el-icon>
{{ project.taskCount || 0 }} 任务
</span>
<span class="stat-item">
<el-icon><TrendCharts /></el-icon>
<el-tag :type="statusType(project.lastRunStatus)" size="small">
{{ project.lastRunStatus ? statusLabel(project.lastRunStatus) : '未执行' }}
</el-tag>
</span>
</div>
<div class="project-last-run" v-if="project.lastRunTime">
最近执行:{{ formatTime(project.lastRunTime) }}
</div>
</div>
<div class="project-actions" @click.stop>
<el-button type="primary" size="small" :loading="runningId === project.id" @click="runProject(project)">
执行全部
</el-button>
<el-button size="small" @click="enterProject(project)">进入项目</el-button>
<el-button size="small" @click="openEditDialog(project)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDeleteProject(project)">删除</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!loading && projects.length === 0" description="暂无项目,点击右上角新建项目" />
<!-- 创建/编辑项目弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑项目' : '创建项目'"
width="500px"
>
<el-form :model="formData" label-width="80px">
<el-form-item label="项目名称" required>
<el-input v-model="formData.name" placeholder="请输入项目名称" maxlength="100" />
</el-form-item>
<el-form-item label="项目类型">
<el-select v-model="formData.projectType" style="width: 100%">
<el-option label="标准版" value="standard" />
<el-option label="项目定制" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="项目图标">
<el-select v-model="formData.icon" style="width: 100%" placeholder="选择图标">
<el-option label="文件夹" value="folder" />
<el-option label="收藏" value="collection" />
<el-option label="文件" value="files" />
<el-option label="数据" value="data" />
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</el-form-item>
<el-form-item label="项目描述">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入项目描述"
maxlength="500"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 性能测试项目管理页面
*
* 功能:
* 1. 标准版 / 项目定制 Tab 切换
* 2. 项目卡片网格展示(任务数、最近执行状态/时间)
* 3. 一键执行整个项目(run-project-all)
* 4. 进入项目详情(/performance/projects/:id)勾选批量执行
* 5. 项目增删改
*/
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Folder, Collection, Files, DataAnalysis, Document, TrendCharts } from '@element-plus/icons-vue'
import { listProjects, createProject, updateProject, deleteProject, runProjectAll } from '@/api/performance'
import type { PerformanceProject, PerformanceProjectCreate, PerformanceProjectUpdate, PerfProjectType } from '@/types/performance'
const router = useRouter()
// ==================== 响应式数据 ====================
const loading = ref(false)
const projects = ref<PerformanceProject[]>([])
const projectType = ref('standard')
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const editingId = ref('')
const runningId = ref('')
const formData = ref<PerformanceProjectCreate>({
name: '',
description: '',
projectType: 'standard',
icon: 'folder',
sortOrder: 0,
})
// ==================== 方法定义 ====================
/** 项目图标组件映射 */
function iconOf(icon: string) {
const map: Record<string, any> = {
folder: Folder,
collection: Collection,
files: Files,
data: DataAnalysis,
}
return map[icon] || Folder
}
/** 加载项目列表(按类型筛选) */
const loadProjects = async () => {
loading.value = true
try {
const response = await listProjects({ page: 1, pageSize: 200, projectType: projectType.value })
projects.value = response.items
} catch (error: any) {
ElMessage.error('加载项目失败: ' + (error.response?.data?.detail || error.message || ''))
} finally {
loading.value = false
}
}
/** 显示创建弹窗 */
const openCreateDialog = () => {
isEdit.value = false
editingId.value = ''
formData.value = { name: '', description: '', projectType: projectType.value as PerfProjectType, icon: 'folder', sortOrder: 0 }
dialogVisible.value = true
}
/** 显示编辑弹窗 */
const openEditDialog = (project: PerformanceProject) => {
isEdit.value = true
editingId.value = project.id
formData.value = {
name: project.name,
description: project.description || '',
projectType: project.projectType || 'standard',
icon: project.icon || 'folder',
sortOrder: project.sortOrder ?? 0,
}
dialogVisible.value = true
}
/** 提交表单 */
const submitForm = async () => {
if (!formData.value.name.trim()) {
ElMessage.warning('请输入项目名称')
return
}
submitting.value = true
try {
if (isEdit.value) {
await updateProject(editingId.value, formData.value as PerformanceProjectUpdate)
ElMessage.success('项目更新成功')
} else {
await createProject(formData.value)
ElMessage.success('项目创建成功')
}
dialogVisible.value = false
await loadProjects()
} catch (error: any) {
ElMessage.error('操作失败: ' + (error.response?.data?.detail || error.message || ''))
} finally {
submitting.value = false
}
}
/** 删除项目(不级联删除任务,仅将任务移出归属) */
const handleDeleteProject = async (project: PerformanceProject) => {
try {
await ElMessageBox.confirm(
`确定要删除项目"${project.name}"吗?该项目下的任务不会被删除,只会被移出该项目!`,
'确认删除',
{ type: 'warning' }
)
await deleteProject(project.id)
ElMessage.success('项目删除成功')
await loadProjects()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('删除失败: ' + (error.response?.data?.detail || error.message || ''))
}
}
}
/** 一键执行整个项目 */
const runProject = async (project: PerformanceProject) => {
const confirmed = await ElMessageBox.confirm(
`确定要执行项目"${project.name}"下的全部任务吗?(串行执行 ${project.taskCount || 0} 个任务)`,
'确认执行',
{ type: 'warning' }
).catch(() => null)
if (confirmed === null) return
runningId.value = project.id
try {
const res = await runProjectAll(project.id)
ElMessage.success(res.message || '项目已开始执行')
} catch (error: any) {
ElMessage.error('执行失败: ' + (error.response?.data?.detail || error.message || ''))
} finally {
runningId.value = ''
}
}
/** 进入项目详情 */
const enterProject = (project: PerformanceProject) => {
router.push(`/performance/projects/${project.id}`)
}
/** 状态标签类型 */
function statusType(status: string | null) {
const map: Record<string, string> = { completed: 'success', running: 'primary', pending: 'info', failed: 'danger', cancelled: 'warning' }
return map[status || ''] || 'info'
}
/** 状态标签文字 */
function statusLabel(status: string) {
const map: Record<string, string> = { completed: '已完成', running: '运行中', pending: '等待中', failed: '失败', cancelled: '已取消' }
return map[status] || status
}
/** 格式化时间 */
function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || ''
}
// ==================== 生命周期 ====================
onMounted(() => {
loadProjects()
})
</script>
<style lang="scss" scoped>
.projects-page {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.header-actions {
display: flex;
gap: 12px;
}
}
.project-card {
margin-bottom: 20px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
.project-icon {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.project-type-tag {
margin-left: auto;
}
}
.project-info {
.project-name {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
.project-desc {
margin: 0 0 12px;
font-size: 13px;
color: #909399;
min-height: 20px;
}
.project-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
}
.project-last-run {
font-size: 12px;
color: #909399;
}
}
.project-actions {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
}
}
</style>
\ No newline at end of file
...@@ -20,12 +20,22 @@ ...@@ -20,12 +20,22 @@
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" /> <el-option label="已取消" value="cancelled" />
</el-select> </el-select>
<el-select v-model="projectFilter" placeholder="全部项目" clearable style="width: 160px" @change="loadTasks">
<el-option label="全部项目" value="" />
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-button @click="loadTasks"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadTasks"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
<el-card shadow="never"> <el-card shadow="never">
<el-table :data="tasks" v-loading="loading" stripe style="width: 100%"> <el-table :data="tasks" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="name" label="任务名称" min-width="160" show-overflow-tooltip /> <el-table-column prop="name" label="任务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="所属项目" width="130" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="row.projectId" size="small">{{ projectName(row.projectId) }}</el-tag>
<span v-else style="color: #909399; font-size: 12px;">未归属</span>
</template>
</el-table-column>
<el-table-column prop="targetUrl" label="目标 URL" min-width="200" show-overflow-tooltip /> <el-table-column prop="targetUrl" label="目标 URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" /> <el-table-column prop="method" label="方法" width="80" />
<el-table-column label="模式" width="90"> <el-table-column label="模式" width="90">
...@@ -109,6 +119,15 @@ ...@@ -109,6 +119,15 @@
<el-input v-model="form.name" placeholder="输入任务名称" /> <el-input v-model="form.name" placeholder="输入任务名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8">
<el-form-item label="归属项目">
<el-select v-model="form.projectId" placeholder="选择项目(可留空)" clearable style="width: 100%">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="接口预设"> <el-form-item label="接口预设">
<el-select <el-select
...@@ -353,8 +372,8 @@ import { ref, onMounted, reactive, computed } from 'vue' ...@@ -353,8 +372,8 @@ import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus, Refresh, Upload, Delete } from '@element-plus/icons-vue' import { Plus, Refresh, Upload, Delete } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl } from '@/api/performance' import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl, listProjects } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule } from '@/types/performance' import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule, PerformanceProject } from '@/types/performance'
const router = useRouter() const router = useRouter()
...@@ -364,6 +383,10 @@ const total = ref(0) ...@@ -364,6 +383,10 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const statusFilter = ref('') const statusFilter = ref('')
const projectFilter = ref('')
// 项目相关(用于筛选列和归属选择器)
const projects = ref<PerformanceProject[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingTask = ref<PerformanceTask | null>(null) const editingTask = ref<PerformanceTask | null>(null)
...@@ -404,6 +427,7 @@ interface TaskForm { ...@@ -404,6 +427,7 @@ interface TaskForm {
assertions: any[] assertions: any[]
presetId: string presetId: string
uniqueFields: UniqueFieldRule[] uniqueFields: UniqueFieldRule[]
projectId: string
} }
const defaultForm = (): TaskForm => ({ const defaultForm = (): TaskForm => ({
...@@ -429,6 +453,7 @@ const defaultForm = (): TaskForm => ({ ...@@ -429,6 +453,7 @@ const defaultForm = (): TaskForm => ({
assertions: [], assertions: [],
presetId: '', presetId: '',
uniqueFields: [], uniqueFields: [],
projectId: '',
}) })
const form = reactive<TaskForm>(defaultForm()) const form = reactive<TaskForm>(defaultForm())
...@@ -456,6 +481,7 @@ async function loadTasks() { ...@@ -456,6 +481,7 @@ async function loadTasks() {
try { try {
const params: any = { page: page.value, pageSize: pageSize.value } const params: any = { page: page.value, pageSize: pageSize.value }
if (statusFilter.value) params.status = statusFilter.value if (statusFilter.value) params.status = statusFilter.value
if (projectFilter.value) params.projectId = projectFilter.value
const res = await listTasks(params) const res = await listTasks(params)
tasks.value = res.items tasks.value = res.items
total.value = res.total total.value = res.total
...@@ -466,12 +492,29 @@ async function loadTasks() { ...@@ -466,12 +492,29 @@ async function loadTasks() {
} }
} }
/** 加载项目列表(用于下拉筛选和归属选择器) */
async function loadProjects() {
try {
const res = await listProjects({ page: 1, pageSize: 200 })
projects.value = res.items || []
} catch {
projects.value = []
}
}
/** 根据项目 ID 获取项目名称 */
function projectName(id: string | null) {
if (!id) return ''
return projects.value.find(p => p.id === id)?.name || ''
}
/** 打开新建弹窗 */ /** 打开新建弹窗 */
async function openCreateDialog() { async function openCreateDialog() {
editingTask.value = null editingTask.value = null
selectedPreset.value = null selectedPreset.value = null
Object.assign(form, defaultForm()) Object.assign(form, defaultForm())
await loadPresets() await loadPresets()
await loadProjects()
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -490,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -490,6 +533,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
// 加载预设列表 // 加载预设列表
await loadPresets() await loadPresets()
await loadProjects()
// 如果任务有 presetId,找到对应的预设并回填 // 如果任务有 presetId,找到对应的预设并回填
const presetId = fullTask.presetId || '' const presetId = fullTask.presetId || ''
...@@ -534,6 +578,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -534,6 +578,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
assertions: fullTask.assertions || [], assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '', presetId: presetId && selectedPreset.value ? presetId : '',
uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })), uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })),
projectId: fullTask.projectId || '',
}) })
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -717,6 +762,7 @@ async function handleSave() { ...@@ -717,6 +762,7 @@ async function handleSave() {
const data: any = { const data: any = {
name: form.name, name: form.name,
description: form.description || null, description: form.description || null,
projectId: form.projectId || null,
targetUrl: form.targetUrl, targetUrl: form.targetUrl,
method: form.method, method: form.method,
headers: form.headers ? JSON.parse(form.headers) : null, headers: form.headers ? JSON.parse(form.headers) : null,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论