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

feat(performance): WebSocket 批量进度订阅端点 + 项目详情实时进度条

- 后端新增 /ws/batch/{batch_id} 端点:订阅 perf_batch_{batch_id} 组,
  连接时从 DB 推送当前批次状态快照,ping/pong 保活
- performance_service 新增 _broadcast_progress():同时推送全局 perf_batch
  与批次专属 perf_batch_{batch_id} 两组
- 前端新增 createPerfBatchStream() WS 连接封装
- ProjectDetail.vue 批量执行实时进度条闭环(连接/刷新/完成自动刷列表/关闭断开)
- types/performance.ts PerfBatchProgress 补 partially_failed/cancelled 类型
- TaskList.vue 文案回归纯中文,仅保留 TPS/Error Rate 英文+中文
- 新增场景测试数据 burst_task.json / endurance_task.json + FieldTip 帮助组件
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 6ab2c268
# HANDOFF_性能测试
> 生成时间:2026-08-12 15:00
> 最后更新:2026-08-22 15:40
> 最后更新:2026-08-24 10:00
> 当前分支:`platform-auto-test`
> 最近提交:`21d65661 docs(device-sim): 更新 HANDOFF 记录设备模拟模块部署到 5.60`
> 主分支:`master`
......@@ -22,13 +22,49 @@
| 批量执行合并报告 | ✅ 已部署 | 项目批量/一键执行生成一份合并报告(聚合指标 + 各任务明细)(2026-08-20) |
| 批量执行跳转监控页 + 3 个 P0 修复 | ✅ 已部署 | BatchMonitor.vue + 500/残留completed/status_2xx 修复(2026-08-21) |
| body 字符串存储导致 4XX 修复 | ✅ 已部署 | 前端 JSON.parse + 后端防御性 json.loads + 数据库脏数据清洗(2026-08-21) |
| 事务处理时间(Transaction) | ✅ 本地完成 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表(2026-08-21) |
| 事务处理时间(Transaction) | ✅ 已部署 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表(2026-08-21) |
| 目标机资源监控(SSH)+ Java 进程监控 | ✅ 已部署 | SSH 采集 CPU/内存/负载 + Java 进程级监控 + 实时监控/报告展示(2026-08-22) |
| **两种压测场景增强(burst/endurance)+ 报告页场景标签/多接口明细表** | **✅ 本地完成,未部署** | 瞬时并发(集合点同步/循环模式/错误策略)+ 8 小时长稳压测(多接口线程组/思考时间分布/CSV 参数化)(2026-08-22) |
| **两种压测场景增强(burst/endurance)+ 报告页场景标签/多接口明细表** | **✅ 已部署,5.60 e2e 验证通过** | 瞬时并发(集合点同步/循环模式/错误策略)+ 8 小时长稳压测(多接口线程组/思考时间分布/CSV 参数化)(2026-08-22) |
---
## 🆕 最新会话进度(2026-08-22 — 两种压测场景增强 burst/endurance + 报告页场景标签/多接口明细表)
## 🆕 当前会话(2026-08-24 — WebSocket 批量进度订阅端点 + TaskList 中文回归)
**任务来源**:P1 遗留「WebSocket 批量进度订阅端点」→ 项目详情页批量执行实时进度条闭环。
### 已完成(本地,待 commit/部署)
| 功能 | 状态 | 说明 |
|------|------|------|
| 后端 `/ws/batch/{batch_id}` 端点 | ✅ 本地完成 | 订阅 `perf_batch_{batch_id}` 组;连接时从 DB 推送当前批次状态快照;ping/pong 保活 |
| 批量进度广播重构 | ✅ 本地完成 | `_broadcast_progress()` 同时推送全局 `perf_batch` + 批次专属 `perf_batch_{batch_id}` 两组 |
| 前端 `createPerfBatchStream()` | ✅ 本地完成 | api/performance.ts 新增批次专属 WS 连接封装 |
| ProjectDetail.vue 进度条闭环 | ✅ 本地完成 | 批量/全部执行后连接批次 WS,实时刷新进度/成功失败数,完成自动刷任务列表,关闭按钮断开 |
| `PerfBatchProgress.currentStatus` 类型修复 | ✅ 本地完成 | 补 `partially_failed` / `cancelled` 联合类型,修复 TS2367 编译错误 |
| TaskList.vue 文案中文回归 | ✅ 本地完成 | 全部标签从「英文+中文备注」改回纯中文;仅保留性能指标列英文+中文(TPS(吞吐量)/ Error Rate(错误率)) |
### 部署 5.60
**已部署(2026-08-22)**:burst/endurance 场景增强 + 报告页场景标签/接口明细表 + Transaction + 目标机监控 + Java 进程监控。
**本次会话改动尚未部署 5.60**(WS 批量进度端点 + TaskList 中文回归),待下次部署。
---
## 最新会话进度(2026-08-22 — 部署到 5.60 + e2e 验证 ✅)
> **部署包**:后端 9 文件 + 前端 dist(93 文件)→ 5.60 服务器
> **修复**:api_summary dict→List[Dict] 格式不匹配 → endurance 报告页正常显示 apiSummary 明细表
> **e2e 验证**:burst/endurance 场景标签 + apiSummary 表格 + 旧任务无回归 + 全部 API 端点 200 ✅
### 已部署功能清单(5.60 全部上线)
| 功能 | 状态 | 说明 |
|------|------|------|
| Transaction 事务处理 | ✅ 已部署 | 多步骤串行事务 + 步骤级耗时统计 + 瀑布图/步骤明细表 |
| Burst 场景增强 | ✅ 已部署 | 集合点同步 + 循环模式(finite/infinite) + 错误策略(continue/stop) |
| Endurance 场景增强 | ✅ 已部署 | 多接口独立线程组 + 思考时间分布 + CSV 参数化 |
| 报告页场景标签 | ✅ 已验证 | burst 橙色 / endurance 红色 / legacy 灰色 |
| 报告页多接口明细表 | ✅ 已验证 | apiSummary 表格:接口名/请求数/成功/失败/错误率/TPS/响应时间分位/标准差 |
**phase 1 后端模型+Schema+迁移**`PerformanceTask` 新增 `scenario_type`(null/burst/endurance)+ 配套字段(loop_type/loop_count/synchronizing_timer_count/synchronizing_timer_timeout/error_action/scenario_apis/think_time_min/think_time_max/think_time_distribution/csv_content)。Pydantic camelCase 别名 + `populate_by_name`
......@@ -42,7 +78,14 @@
**验证**:schema 冒烟 3 种 payload ✅ / `PerformanceReportResponse` 构造 ✅ / pytest 25 passed ✅ / npm build ~26s ✅
**⚠️ 未部署 5.60**,见 P0 部署任务。
**✅ 已部署 5.60(2026-08-22 16:00)**:后端 9 文件(models/schemas/database/executors/routers/services + 目标机 monitor)+ `_ensure_columns` 自动迁移 + performance_tasks 手动补 `csv_content`/`csv_variable_mapping` 两列(MySQL TEXT 不支持 DEFAULT)→ 前端 dist 全量覆盖 → 容器重启 healthy。
**e2e 验证全通过**
- burst 任务(perf_97fe...):执行 completed,15 req / 2.88 tps / 0% 错误率 → 报告页橙色标签「瞬时并发 Burst」✅
- endurance 任务(perf_3b50...):执行 completed,107 req / 1.83 tps / 2 接口 → 报告页红色标签「长稳压测 Endurance」+ apiSummary 明细表 2 行(会议列表 60 req / 模板列表 47 req)✅
- legacy 旧任务:灰色标签「标准 Standard」,apiSummary 为空不显示表格(无回归)✅
- **api_summary 格式修复**:执行器 `_build_api_summary()` 由 dict 改为 `List[Dict]`(camelCase 匹配前端 `ApiSummaryData` 接口),`PerformanceTaskResponse.api_summary` 同步 `Dict``List[Dict]`,修复 endurance 报告 API 500
- 冒烟测试全部 API 端点 200 ✅
---
......@@ -63,7 +106,7 @@
| 08-17 | 会议预约 API 端到端验证 + A0027 修复(stale pyc 坑) | ✅ 已部署 |
| 08-19 | 读取系统配置 URL + 自定义登录凭据 + curl 导入 + 修复 curl [object Object] + 修复保存 422 | ✅ 已部署 |
| 08-20 | 项目管理与批量执行 + 合并报告 + 指标体系增强(10 项新指标,25 单测) | ✅ 已部署 |
| 08-21 | 资源图表宽度修复 + 目标机监控 PRD + Transaction 前后端(瀑布图/步骤明细表) | ⚠️ Transaction 未部署 |
| 08-21 | 资源图表宽度修复 + 目标机监控 PRD + Transaction 前后端(瀑布图/步骤明细表) | ✅ 已部署(08-22) |
| 08-21 | 批量执行跳转监控页(BatchMonitor)+ 3 个 P0 修复(500/残留completed/status_2xx)+ body 4XX 修复 | ✅ 已部署 |
---
......@@ -72,12 +115,12 @@
### 生产服务器 192.168.5.60
- 容器 `plat-auto-test-app`:healthy,内部端口 80
- **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控
- **未部署**Transaction 事务处理时间(前后端)、burst/endurance 场景增强(前后端
- **已部署**:curl 导入 + 自定义凭据 + 读取系统配置 URL + 项目管理与批量执行 + 合并报告 + 指标体系增强 + BatchMonitor + 3 个 P0 修复 + body 4XX 修复 + 目标机监控 + Java 进程监控 + Transaction 事务处理 + burst/endurance 场景增强 + 报告页场景标签/多接口明细表
- **未部署**无(所有功能已部署
### 本地(Windows 开发机)
- 后端 8001 / 前端 3000 | 数据库 `data/test_platform.db`
- **最新改动**burst/endurance 场景增强 + 报告页场景标签/接口明细表(后端 5 文件 + 前端 3 文件,本地 build+单测通过,未部署)
- **最新改动**WebSocket 批量进度订阅端点(后端 2 文件)+ ProjectDetail 实时进度条 + TaskList 中文回归(前端 4 文件,本地 build 通过,未部署)
---
......@@ -117,17 +160,8 @@ Docs/PRD/性能测试/问题处理/ # 各问题
## 下一步任务
### P0 — 部署场景增强(burst/endurance)到 5.60 + e2e 验证
1. 部署后端 5 文件:`models/performance.py` / `schemas/performance.py` / `database.py` / `executors/performance_executor.py` / `routers/performance.py`
2. 前端 `npm run build` → dist 全量 scp
3.`*.pyc` + `docker compose restart app` → healthy(验证 `_ensure_columns` 自动迁移)
4. e2e:burst 任务→橙色标签 / endurance 任务→红色标签+apiSummary 表 / legacy 任务→标准 Standard
### P0 — 部署 Transaction 到 5.60 + e2e 验证
后端 3 文件(models/schemas/executors)+ 前端 build → 验证瀑布图+步骤明细表
### P1 — WebSocket 批量进度订阅端点
后端缺 `/ws/batch/{batch_id}` 订阅端点 → ProjectDetail 实时进度条收不到推送
### P1 — 待办
- ✅ WebSocket 批量进度订阅端点 **已完成(2026-08-24 本地)**,待部署 5.60
### P1 — 按请求序号错开时间段(并发预约类接口关键)
`template_resolver.py` 增加 `INDEX` 后缀支持,如 `{__NOW_+37_H_INDEX__}`
......
......@@ -306,18 +306,20 @@ class MetricsCollector:
"error_type_assertion": self._error_counts.get("assertion_fail", 0),
}
def _build_api_summary(self) -> Optional[dict]:
def _build_api_summary(self) -> Optional[list]:
"""
构建接口维度指标汇总(长稳压测多接口场景,执行结束后调用)
Returns:
dict: {api_name: {count, avgRt, minRt, maxRt, p50, p90, p95, p99,
tps, errorRate, status2xx, status3xx, status4xx, status5xx}}
list: [{name, totalRequests, successCount, failCount, avgResponseTime,
minResponseTime, maxResponseTime, p50ResponseTime, p90ResponseTime,
p95ResponseTime, p99ResponseTime, stdDev, errorRate, actualTps,
status2xx, status3xx, status4xx, status5xx, sentBytes, receivedBytes}]
无接口数据返回 None
"""
if not self._api_stats:
return None
result = {}
result = []
elapsed = max(time.time() - self._start_time, 1)
for api_name, stats in self._api_stats.items():
times = stats["times"]
......@@ -329,26 +331,31 @@ class MetricsCollector:
p90 = sorted_t[int(n * 0.9)] if n > 1 else sorted_t[0]
p95 = sorted_t[int(n * 0.95)] if n > 1 else sorted_t[0]
p99 = sorted_t[int(n * 0.99)] if n > 1 else sorted_t[0]
result[api_name] = {
"count": stats["count"],
mean = sum(times) / n
std_dev = math.sqrt(sum((x - mean) ** 2 for x in times) / n) if n > 1 else 0
fail_count = stats["count"] - stats["success"]
result.append({
"name": api_name,
"totalRequests": stats["count"],
"successCount": stats["success"],
"failCount": stats["count"] - stats["success"],
"errorRate": round((stats["count"] - stats["success"]) / max(stats["count"], 1) * 100, 2),
"avgResponseTime": round(sum(times) / n, 2) if n else 0,
"failCount": fail_count,
"avgResponseTime": round(mean, 2),
"minResponseTime": round(min(times), 2) if n else 0,
"maxResponseTime": round(max(times), 2) if n else 0,
"p50": round(p50, 2),
"p90": round(p90, 2),
"p95": round(p95, 2),
"p99": round(p99, 2),
"tps": round(stats["success"] / elapsed, 2),
"p50ResponseTime": round(p50, 2),
"p90ResponseTime": round(p90, 2),
"p95ResponseTime": round(p95, 2),
"p99ResponseTime": round(p99, 2),
"stdDev": round(std_dev, 2),
"errorRate": round(fail_count / max(stats["count"], 1), 4),
"actualTps": round(stats["success"] / elapsed, 2),
"status2xx": stats["status_2xx"],
"status3xx": stats["status_3xx"],
"status4xx": stats["status_4xx"],
"status5xx": stats["status_5xx"],
"sentBytes": stats["sent_bytes"],
"receivedBytes": stats["received_bytes"],
}
})
return result
@staticmethod
......
......@@ -344,6 +344,75 @@ async def websocket_batch_progress(websocket: WebSocket):
await manager.disconnect(websocket, ws_group)
@router.websocket("/ws/batch/{batch_id}")
async def websocket_batch_progress_specific(websocket: WebSocket, batch_id: str):
"""
批量执行进度 WebSocket(按批次分组)
客户端连接后订阅 perf_batch_{batch_id} 分组,只收到该批次的进度广播,
避免多批次并发时全局分组串扰。
连接建立后立即推送当前批次状态快照(若记录已存在),
防止客户端错过连接前已完成的进度。
消息格式(perf_batch_progress):
```json
{
"type": "perf_batch_progress",
"batch_id": "...",
"project_id": "...",
"current_index": 1,
"total_count": 5,
"current_task_id": "...",
"current_task_name": "任务名",
"current_status": "running",
"success_count": 1,
"fail_count": 0
}
```
"""
ws_group = f"perf_batch_{batch_id}"
await manager.connect(websocket, ws_group)
# 连接建立后推送当前批次状态快照(若记录已存在)
try:
async with async_session_maker() as db:
service = PerformanceService(db)
details = await service.get_batch_details(batch_id)
if details:
done_count = (details.get("success_count") or 0) + (details.get("fail_count") or 0)
await manager.send_to(websocket, {
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": details.get("project_id"),
"current_index": done_count,
"total_count": details.get("total_count") or 0,
"current_task_id": None,
"current_task_name": None,
"current_status": details.get("status") or "running",
"success_count": details.get("success_count") or 0,
"fail_count": details.get("fail_count") or 0,
})
except Exception as e:
logger.warning(f"推送批量进度快照失败: {e}")
try:
# 保持连接,等待推送
while True:
try:
data = await websocket.receive_text()
msg = json.loads(data)
if msg.get("type") == "ping":
await manager.send_to(websocket, {"type": "pong"})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
logger.info(f"批量进度 WebSocket 断开连接: batch_id={batch_id}")
except Exception as e:
logger.error(f"批量进度 WebSocket 异常: {e}")
finally:
await manager.disconnect(websocket, ws_group)
@router.websocket("/ws/{task_id}")
async def websocket_monitor(websocket: WebSocket, task_id: str):
"""
......
......@@ -273,8 +273,8 @@ class PerformanceTaskResponse(BaseModel):
think_time_max: int = 3000
think_time_distribution: str = "constant"
csv_parameterization_enabled: bool = False
csv_content: str = ""
csv_variable_mapping: Dict[str, str] = Field(default_factory=dict)
csv_content: Optional[str] = None
csv_variable_mapping: Optional[Dict[str, str]] = None
# 资源监控汇总
resource_summary: Optional[Dict[str, Any]] = None
......@@ -283,7 +283,7 @@ class PerformanceTaskResponse(BaseModel):
# 事务处理汇总
transaction_summary: Optional[Dict[str, Any]] = None
# 接口维度指标汇总
api_summary: Optional[Dict[str, Any]] = None
api_summary: Optional[List[Dict[str, Any]]] = None
# 结果统计
total_requests: int = 0
......
......@@ -1353,10 +1353,20 @@ class PerformanceService:
fail_count = 0
results = []
# 广播分组:全局分组(BatchMonitor 兼容)+ 批次专属分组(/ws/batch/{batch_id})
ws_group = "perf_batch"
batch_group = f"perf_batch_{batch_id}"
async def _broadcast_progress(payload: Dict[str, Any]) -> None:
"""同时广播到全局分组与批次专属分组"""
for group in (ws_group, batch_group):
try:
await manager.broadcast(group, payload)
except Exception as e:
logger.warning(f"批量进度广播失败 [{group}]: {e}")
# 广播批量执行开始
await manager.broadcast(ws_group, {
await _broadcast_progress({
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
......@@ -1378,7 +1388,7 @@ class PerformanceService:
task_name = task.name if task else task_id
# 广播当前任务开始
await manager.broadcast(ws_group, {
await _broadcast_progress({
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
......@@ -1414,7 +1424,7 @@ class PerformanceService:
})
# 广播当前任务完成
await manager.broadcast(ws_group, {
await _broadcast_progress({
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
......@@ -1429,7 +1439,7 @@ class PerformanceService:
# 广播批量执行完成
final_status = "completed" if fail_count == 0 else "partially_failed"
await manager.broadcast(ws_group, {
await _broadcast_progress({
"type": "perf_batch_progress",
"batch_id": batch_id,
"project_id": project_id,
......
{
"name": "burst场景-会议列表查询",
"targetUrl": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"method": "GET",
"concurrency": 5,
"duration": 30,
"scenarioType": "burst",
"loopType": "finite",
"loopCount": 3,
"synchronizingTimerEnabled": true,
"synchronizingTimerCount": 5,
"synchronizingTimerTimeout": 30000,
"errorAction": "continue",
"authRequired": true,
"accountKey": "superadmin",
"signRequest": false
}
\ No newline at end of file
{
"name": "endurance场景-多接口长稳",
"targetUrl": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"method": "GET",
"concurrency": 3,
"duration": 30,
"scenarioType": "endurance",
"scenarioApis": [
{
"name": "会议列表查询",
"method": "GET",
"url": "https://192.168.5.44/meetingV3/api/message/getMessagePageList?companyNumber=CN-WQF-UBAINS&queryType=0&pageNum=1&pageSize=10",
"threads": 2,
"headers": {},
"body": null
},
{
"name": "模板列表查询",
"method": "GET",
"url": "https://192.168.5.44/meetingV3/api/template/getTemplatePage?pageNum=1&pageSize=10&companyNumber=CN-WQF-UBAINS&keyword=",
"threads": 1,
"headers": {},
"body": null
}
],
"thinkTimeEnabled": true,
"thinkTimeMin": 500,
"thinkTimeMax": 1000,
"thinkTimeDistribution": "constant",
"authRequired": true,
"accountKey": "superadmin",
"signRequest": false
}
\ No newline at end of file
......@@ -140,6 +140,40 @@ export function createPerfMonitorStream(
return ws
}
/** 创建 WebSocket 连接订阅批量执行进度(按批次分组) */
export function createPerfBatchStream(
batchId: string,
onProgress: (data: any) => void,
onError?: (error: Event) => void,
onClose?: () => void,
): WebSocket {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const ws = new WebSocket(`${protocol}//${host}/api/performance/ws/batch/${batchId}`)
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'perf_batch_progress') {
onProgress(msg)
}
} catch (e) {
console.error('解析批量进度 WebSocket 消息失败', e)
}
}
ws.onerror = (error) => {
console.error('批量进度 WebSocket 错误', error)
onError?.(error)
}
ws.onclose = () => {
onClose?.()
}
return ws
}
// ==================== API 接口预设 ====================
/** 获取预设列表 */
......
<!--
组件名称:FieldTip.vue
组件描述:表单参数帮助提示图标(?悬浮查看参数说明)
@author czj
@date 2026-08-22
-->
<template>
<el-tooltip :content="content" placement="top">
<el-icon style="margin-left: 4px; cursor: help; color: #909399; vertical-align: middle;">
<QuestionFilled />
</el-icon>
</el-tooltip>
</template>
<script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue'
defineProps<{
/** 悬浮提示文案 */
content: string
}>()
</script>
\ No newline at end of file
......@@ -805,7 +805,7 @@ export interface PerfBatchProgress {
totalCount: number
currentTaskId: string
currentTaskName: string
currentStatus: 'running' | 'completed' | 'failed'
currentStatus: 'running' | 'completed' | 'failed' | 'partially_failed' | 'cancelled'
successCount: number
failCount: number
}
......
......@@ -43,6 +43,49 @@
{{ project.description }}
</div>
<!-- 批量执行实时进度卡片 -->
<el-card v-if="batchProgress" shadow="never" class="batch-progress-card">
<div class="batch-progress-header">
<span>
<el-icon style="vertical-align: -2px; margin-right: 4px"><Loading v-if="isBatchRunning" /></el-icon>
Batch Progress(批量执行进度)
</span>
<el-tag :type="batchStatusType(batchProgress.currentStatus)" size="small">
{{ batchStatusLabel(batchProgress.currentStatus) }}
</el-tag>
</div>
<div class="batch-progress-body">
<el-progress
:percentage="batchPercent"
:status="batchProgress.currentStatus === 'running' ? undefined : (batchProgress.failCount > 0 ? 'exception' : 'success')"
:stroke-width="14"
:text-inside="true"
/>
<div class="batch-progress-info">
<span class="batch-progress-count">
{{ batchProgress.successCount }} Success(成功) / {{ batchProgress.failCount }} Failed(失败)
</span>
<span>
{{ batchProgress.currentIndex }} / {{ batchProgress.totalCount }}{{ batchPercent }}%)
</span>
</div>
<div v-if="batchProgress.currentTaskName" class="batch-progress-text">
Current Task(当前任务): {{ batchProgress.currentTaskName }}
<el-button link type="primary" size="small" style="margin-left: 12px" @click="disconnectBatchWs">
关闭进度
</el-button>
</div>
<div v-else class="batch-progress-text">
<el-button v-if="!isBatchRunning && batchProgress.totalCount > 0" link type="primary" size="small" @click="viewBatchMonitor">
查看批次明细
</el-button>
<el-button link type="primary" size="small" style="margin-left: 12px" @click="disconnectBatchWs">
关闭进度
</el-button>
</div>
</div>
</el-card>
<!-- 任务列表 -->
<el-card shadow="never">
<div class="toolbar">
......@@ -158,12 +201,12 @@
* 6. 单任务执行/停止/删除/跳转监控报告
*/
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, VideoPlay, Refresh, Document, List } from '@element-plus/icons-vue'
import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks } from '@/api/performance'
import type { PerformanceProject, PerformanceTaskListItem } from '@/types/performance'
import { ArrowLeft, VideoPlay, Refresh, Document, List, Loading } from '@element-plus/icons-vue'
import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks, createPerfBatchStream } from '@/api/performance'
import type { PerformanceProject, PerformanceTaskListItem, PerfBatchProgress } from '@/types/performance'
const router = useRouter()
const route = useRoute()
......@@ -188,6 +231,15 @@ const tableRef = ref()
const lastReportId = ref<string | null>(null) // 最近一次批量执行生成的合并报告 ID
// 批量执行进度
const batchProgress = ref<PerfBatchProgress | null>(null)
const batchWs = ref<WebSocket | null>(null)
const isBatchRunning = computed(() => batchProgress.value?.currentStatus === 'running')
const batchPercent = computed(() => {
if (!batchProgress.value || batchProgress.value.totalCount === 0) return 0
return Math.round((batchProgress.value.currentIndex / batchProgress.value.totalCount) * 100)
})
// ==================== 方法定义 ====================
/** 返回项目管理页 */
......@@ -275,11 +327,21 @@ async function handleRunAll() {
runningAll.value = true
try {
const res = await runProjectAll(projectId.value)
// 立即跳转批量监控页
router.push({
path: '/performance/batch-monitor',
query: { batchId: res.batchId, projectId: projectId.value },
})
// 连接批次专属 WebSocket 订阅进度
connectBatchWs(res.batchId)
// 初始化进度
batchProgress.value = {
type: 'perf_batch_progress',
batchId: res.batchId,
projectId: projectId.value,
currentIndex: 0,
totalCount: res.taskCount || res.taskIds?.length || 0,
currentTaskId: '',
currentTaskName: '',
currentStatus: 'running',
successCount: 0,
failCount: 0,
}
} catch (e: any) {
ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
runningAll.value = false
......@@ -303,11 +365,21 @@ async function handleBatchRun() {
runningBatch.value = true
try {
const res = await batchRunTasks({ taskIds: selectedIds.value, projectId: projectId.value })
// 立即跳转批量监控页
router.push({
path: '/performance/batch-monitor',
query: { batchId: res.batchId, projectId: projectId.value },
})
// 连接批次专属 WebSocket 订阅进度
connectBatchWs(res.batchId)
// 初始化进度
batchProgress.value = {
type: 'perf_batch_progress',
batchId: res.batchId,
projectId: projectId.value,
currentIndex: 0,
totalCount: res.taskCount || selectedIds.value.length,
currentTaskId: '',
currentTaskName: '',
currentStatus: 'running',
successCount: 0,
failCount: 0,
}
} catch (e: any) {
ElMessage.error('批量执行失败: ' + (e.response?.data?.detail || e.message || ''))
runningBatch.value = false
......@@ -342,6 +414,76 @@ function viewHistoryReports() {
router.push(`/performance/project-report?projectId=${projectId.value}`)
}
/** 跳转批量监控页 */
function viewBatchMonitor() {
if (batchProgress.value?.batchId) {
router.push({
path: '/performance/batch-monitor',
query: { batchId: batchProgress.value.batchId, projectId: projectId.value },
})
}
}
// ==================== 批量进度 WebSocket ====================
/** 连接批次专属 WebSocket 订阅批量执行进度 */
function connectBatchWs(batchId: string) {
disconnectBatchWs()
batchWs.value = createPerfBatchStream(
batchId,
handleBatchProgress,
() => { /* 连接错误,进度条停在上次状态 */ },
() => { batchWs.value = null },
)
}
/** 断开批量进度 WebSocket */
function disconnectBatchWs() {
if (batchWs.value) {
batchWs.value.close()
batchWs.value = null
}
}
/** 处理批量进度消息 */
function handleBatchProgress(msg: PerfBatchProgress) {
batchProgress.value = msg
// 批量执行完成时刷新任务列表(状态可能有变化)
if (msg.currentStatus === 'completed' || msg.currentStatus === 'partially_failed') {
loadTasks()
loadProject()
}
// 开始执行后可以停止按钮 loading
if (msg.currentIndex > 0) {
runningAll.value = false
runningBatch.value = false
}
}
/** 批量状态标签类型 */
function batchStatusType(status: string) {
const map: Record<string, string> = {
running: 'primary',
completed: 'success',
partially_failed: 'warning',
failed: 'danger',
cancelled: 'info',
}
return map[status] || 'info'
}
/** 批量状态标签文字 */
function batchStatusLabel(status: string) {
const map: Record<string, string> = {
running: 'Running(执行中)',
completed: 'Completed(已完成)',
partially_failed: 'Partially Failed(部分失败)',
failed: 'Failed(失败)',
cancelled: 'Cancelled(已取消)',
}
return map[status] || status || 'Running(执行中)'
}
/** 格式化时间 */
function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || ''
......@@ -352,6 +494,10 @@ onMounted(() => {
loadProject()
loadTasks()
})
onUnmounted(() => {
disconnectBatchWs()
})
</script>
<style scoped>
......
......@@ -12,7 +12,7 @@
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新建任务
</el-button>
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 140px" @change="loadTasks">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 160px" @change="loadTasks">
<el-option label="全部" value="" />
<el-option label="等待中" value="pending" />
<el-option label="运行中" value="running" />
......@@ -20,7 +20,7 @@
<el-option label="失败" value="failed" />
<el-option label="已取消" value="cancelled" />
</el-select>
<el-select v-model="projectFilter" placeholder="全部项目" clearable style="width: 160px" @change="loadTasks">
<el-select v-model="projectFilter" placeholder="全部项目" clearable style="width: 180px" @change="loadTasks">
<el-option label="全部项目" value="" />
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
......@@ -50,7 +50,7 @@
<span v-else style="color: #909399; font-size: 12px;">标准</span>
</template>
</el-table-column>
<el-table-column label="Concurrency(并发)" width="70" prop="concurrency" />
<el-table-column label="并发数" width="70" prop="concurrency" />
<el-table-column label="时长" width="80">
<template #default="{ row }">{{ row.duration }}s</template>
</el-table-column>
......@@ -122,57 +122,26 @@
<el-form ref="formRef" :model="form" label-position="top" :rules="formRules">
<el-row :gutter="16">
<el-col :span="16">
<el-form-item label="任务名称" prop="name">
<el-form-item prop="name">
<template #label>
任务名称
<FieldTip content="任务名称,用于标识和搜索该任务" />
</template>
<el-input v-model="form.name" placeholder="输入任务名称" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="归属项目">
<el-form-item>
<template #label>
归属项目
<FieldTip content="将任务归属到项目,便于按项目批量执行与合并报告" />
</template>
<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-form-item label="接口预设">
<el-select
v-model="form.presetId"
placeholder="选择预设…"
clearable
style="width: 100%"
@change="onPresetChange"
>
<el-option label="无预设(手动填写)" value="" />
<el-option v-for="p in presets" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 预设摘要卡片 -->
<el-card v-if="selectedPreset" shadow="never" class="preset-summary">
<template #header>
<div class="preset-summary-header">
<span class="preset-summary-title">{{ selectedPreset.name }}</span>
<el-tag size="small" :type="methodTagType(selectedPreset.method)">{{ selectedPreset.method }}</el-tag>
</div>
</template>
<div class="preset-summary-body">
<div><strong>目标 URL:</strong><code>{{ selectedPreset.targetUrl }}</code></div>
<div v-if="selectedPreset.description"><strong>描述:</strong>{{ selectedPreset.description }}</div>
<div>
<strong>认证:</strong>
{{ selectedPreset.authRequired ? '需要登录' : '无需登录' }}<template v-if="selectedPreset.authRequired">{{ selectedPreset.credentialName || accountKeyLabel(selectedPreset.accountKey) }}</template>
· 签名:{{ selectedPreset.signRequest ? '需要' : '不需要' }}
</div>
<el-button link type="primary" size="small" class="preset-summary-clear" @click="onPresetChange('')">
清除预设改为手动
</el-button>
</div>
</el-card>
<!-- 从 curl 导入 -->
<div style="margin-bottom: 12px">
<el-button type="info" size="small" @click="openCurlDialog">
......@@ -183,8 +152,12 @@
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="HTTP 方法" prop="method">
<el-select v-model="form.method" style="width: 100%" :disabled="!!form.presetId">
<el-form-item prop="method">
<template #label>
HTTP 方法
<FieldTip content="请求的 HTTP 方法:GET(查询)、POST(新增)、PUT(更新)、DELETE(删除)" />
</template>
<el-select v-model="form.method" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
......@@ -193,15 +166,23 @@
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="目标 URL" prop="targetUrl">
<el-input v-model="form.targetUrl" placeholder="https://192.168.5.44/platform/api/..." :disabled="!!form.presetId" />
<el-form-item prop="targetUrl">
<template #label>
目标 URL
<FieldTip content="被测接口的完整地址。保留 {{param}} 占位符可传参,如 https://192.168.5.44/api/user/{{id}}" />
</template>
<el-input v-model="form.targetUrl" placeholder="https://192.168.5.44/platform/api/..." />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="压测模式" prop="mode">
<el-form-item prop="mode">
<template #label>
压测模式
<FieldTip content="并发模式:固定并发数持续压测;QPS 模式:控制每秒请求数;阶梯模式:逐步增加并发" />
</template>
<el-select v-model="form.mode" style="width: 100%" @change="onModeChange">
<el-option label="并发模式" value="concurrent" />
<el-option label="QPS 模式" value="qps" />
......@@ -210,16 +191,24 @@
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="场景类型">
<el-form-item>
<template #label>
场景类型
<FieldTip content="标准模式:常规压测;瞬时并发 Burst:所有用户同时发请求(集合点);长稳压测 Endurance:持续较长时间运行" />
</template>
<el-select v-model="form.scenarioType" placeholder="标准模式" clearable style="width: 100%" @change="onScenarioTypeChange">
<el-option label="标准模式" value="" />
<el-option label="瞬时并发 (burst/spike)" value="burst" />
<el-option label="长稳压测 (endurance)" value="endurance" />
<el-option label="瞬时并发(Burst)" value="burst" />
<el-option label="长稳压测(Endurance)" value="endurance" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="并发数" prop="concurrency">
<el-form-item prop="concurrency">
<template #label>
并发数
<FieldTip content="同时发送请求的虚拟用户数。模拟多用户并发访问,总并发数不应超过服务器承载能力" />
</template>
<el-input-number v-model="form.concurrency" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
......@@ -227,17 +216,29 @@
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="压测时长(秒)" prop="duration">
<el-form-item prop="duration">
<template #label>
压测时长(秒)
<FieldTip content="压测持续的总时间(秒)。建议按场景设定:快速验证 30-60s,正式测试 120-300s" />
</template>
<el-input-number v-model="form.duration" :min="5" :max="3600" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item v-if="form.mode === 'qps'" label="目标 QPS" prop="qpsTarget">
<el-form-item v-if="form.mode === 'qps'" prop="qpsTarget">
<template #label>
目标 QPS
<FieldTip content="每秒期望发送的请求数。系统会动态调整并发数以维持目标 QPS,适合控制吞吐量场景" />
</template>
<el-input-number v-model="form.qpsTarget" :min="1" :max="100000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="预热时间(秒)" prop="rampUp">
<el-form-item prop="rampUp">
<template #label>
预热时间(秒)
<FieldTip content="从 0 逐步增加到目标并发数的时间。预热可避免瞬间打满造成误报,0 表示立即满负载" />
</template>
<el-input-number v-model="form.rampUp" :min="0" :max="300" style="width: 100%" />
</el-form-item>
</el-col>
......@@ -245,60 +246,88 @@
<el-row :gutter="16" v-if="form.mode === 'step'">
<el-col :span="8">
<el-form-item label="阶梯并发数" prop="stepConcurrency">
<el-form-item prop="stepConcurrency">
<template #label>
阶梯并发数
<FieldTip content="阶梯模式的最终并发数。从预热结束后的并发逐步增加到该值,用于观察系统在不同并发下的表现" />
</template>
<el-input-number v-model="form.stepConcurrency" :min="1" :max="500" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ==================== 瞬时并发 Burst 配置 ==================== -->
<!-- ==================== 瞬时并发配置 ==================== -->
<template v-if="form.scenarioType === 'burst'">
<el-divider content-position="left">瞬时并发 Burst 配置</el-divider>
<el-divider content-position="left">瞬时并发配置</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="循环类型">
<el-form-item>
<template #label>
循环类型
<FieldTip content="有限循环:按指定次数循环;无限循环:在压测时长内持续循环,适合长时间稳定性测试" />
</template>
<el-select v-model="form.loopType" style="width: 100%">
<el-option label="有限循环" value="finite" />
<el-option label="无限循环(持续至时长结束)" value="infinite" />
<el-option label="无限循环" value="infinite" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item v-if="form.loopType === 'finite'" label="循环次数">
<el-form-item v-if="form.loopType === 'finite'">
<template #label>
循环次数
<FieldTip content="每个虚拟用户执行请求的循环次数。如设 10,则每个用户连续发 10 次请求" />
</template>
<el-input-number v-model="form.loopCount" :min="1" :max="100000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="请求失败时">
<el-form-item>
<template #label>
请求失败时
<FieldTip content="continue:请求失败后继续执行,统计错误率;stop:请求失败时立即终止整个压测" />
</template>
<el-select v-model="form.errorAction" style="width: 100%">
<el-option label="继续执行 (continue)" value="continue" />
<el-option label="立即停止 (stop)" value="stop" />
<el-option label="继续执行" value="continue" />
<el-option label="立即停止" value="stop" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="集合点同步">
<el-form-item>
<template #label>
集合点同步
<FieldTip content="启用后所有虚拟用户在同一时刻发送请求,模拟瞬时高并发场景。关闭则每个用户独立发送" />
</template>
<el-switch v-model="form.synchronizingTimerEnabled" />
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.synchronizingTimerEnabled">
<el-form-item label="集合点线程数">
<el-form-item>
<template #label>
集合点线程数
<FieldTip content="达到该数量的虚拟用户后同时放行。建议等于总并发数,使所有用户同时发起请求" />
</template>
<el-input-number v-model="form.synchronizingTimerCount" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.synchronizingTimerEnabled">
<el-form-item label="集合点超时(ms)">
<el-form-item>
<template #label>
集合点超时(ms)
<FieldTip content="等待其他虚拟用户到达集合点的最长时间。超时后已到达的用户先执行,防止永久阻塞" />
</template>
<el-input-number v-model="form.synchronizingTimerTimeout" :min="1000" :max="120000" :step="1000" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</template>
<!-- ==================== 长稳压测 Endurance 配置 ==================== -->
<!-- ==================== 长稳压测配置 ==================== -->
<template v-if="form.scenarioType === 'endurance'">
<el-divider content-position="left">长稳压测 Endurance 配置</el-divider>
<el-divider content-position="left">长稳压测配置</el-divider>
<!-- 多接口编辑器 -->
<div class="txn-tip">
......@@ -322,40 +351,60 @@
<el-input v-model="api.url" placeholder="目标 URL" />
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="虚拟用户数">
<el-form-item>
<template #label>
虚拟用户数
<FieldTip content="该接口分配的独立虚拟用户数。总并发 = 各接口线程数之和,模拟不同负载比例" />
</template>
<el-input-number v-model="api.threadCount" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="16">
<el-input v-model="api.headers" type="textarea" :rows="1" placeholder='请求头(JSON),可选,如 {"Content-Type": "application/json"}' />
<el-input v-model="api.headers" type="textarea" :rows="1" placeholder='请求头(JSON),可选,如 {"Content-Type": "application/json"}' />
</el-col>
</el-row>
<el-input v-model="api.body" type="textarea" :rows="2" placeholder="请求体(JSON),可选" />
<el-input v-model="api.body" type="textarea" :rows="2" placeholder="请求体(JSON),可选" />
</div>
<el-button type="primary" plain :icon="Plus" @click="addScenarioApi">添加接口</el-button>
<!-- 思考时间 -->
<el-divider content-position="left">思考时间 Think Time</el-divider>
<el-divider content-position="left">思考时间</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用思考时间">
<el-form-item>
<template #label>
启用思考时间
<FieldTip content="启用后每个请求间插入等待时间,模拟真实用户操作间隔,更接近实际使用场景" />
</template>
<el-switch v-model="form.thinkTimeEnabled" />
</el-form-item>
</el-col>
<el-col :span="6" v-if="form.thinkTimeEnabled">
<el-form-item label="最小间隔(ms)">
<el-form-item>
<template #label>
最小间隔(ms)
<FieldTip content="思考时间的最小值(毫秒)。实际等待时间在最小值和最大值之间随机" />
</template>
<el-input-number v-model="form.thinkTimeMin" :min="0" :max="60000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6" v-if="form.thinkTimeEnabled">
<el-form-item label="最大间隔(ms)">
<el-form-item>
<template #label>
最大间隔(ms)
<FieldTip content="思考时间的最大值(毫秒)。实际等待时间在最小值和最大值之间随机" />
</template>
<el-input-number v-model="form.thinkTimeMax" :min="0" :max="60000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="6" v-if="form.thinkTimeEnabled">
<el-form-item label="分布策略">
<el-form-item>
<template #label>
分布策略
<FieldTip content="恒定:使用中点值((Min+Max)/2)作为固定等待时间;高斯分布:按正态分布随机取值,更加自然" />
</template>
<el-select v-model="form.thinkTimeDistribution" style="width: 100%">
<el-option label="恒定 (中点值)" value="constant" />
<el-option label="恒定(中点值)" value="constant" />
<el-option label="高斯分布" value="gaussian" />
</el-select>
</el-form-item>
......@@ -366,88 +415,55 @@
<el-divider content-position="left">CSV 参数化</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用 CSV 参数化">
<el-form-item>
<template #label>
启用 CSV 参数化
<FieldTip content="启用后从 CSV 数据文件中按行读取数据,配合字段映射注入请求,实现多用户不同数据" />
</template>
<el-switch v-model="form.csvParameterizationEnabled" />
</el-form-item>
</el-col>
</el-row>
<template v-if="form.csvParameterizationEnabled">
<el-form-item label="CSV 数据内容">
<el-form-item>
<template #label>
CSV 数据内容
<FieldTip content="第一行为字段名,后续每行为一条数据。最多支持 500 行,每行数据会被遍历使用" />
</template>
<el-input v-model="form.csvContent" type="textarea" :rows="4" placeholder="第一行为字段名,后续行为数据行&#10;username,password&#10;user1,pass1&#10;user2,pass2" />
</el-form-item>
<el-form-item label="字段映射 (JSON)">
<el-form-item>
<template #label>
字段映射(JSON)
<FieldTip content="将 CSV 列映射到请求变量。key 为请求字段,value 用 {{csv.列名}} 引用,如 {&quot;username&quot;: &quot;{{csv.username}}&quot;}" />
</template>
<el-input v-model="form.csvVariableMapping" type="textarea" :rows="2" placeholder='{"username": "{{csv.username}}", "password": "{{csv.password}}"}' />
</el-form-item>
</template>
</template>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="请求头(JSON)" prop="headers">
<el-input v-model="form.headers" type="textarea" :rows="2" placeholder='{"Content-Type": "application/json"}' :disabled="!!form.presetId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item v-if="!form.presetId" label="请求体(JSON)" prop="body">
<el-input v-model="form.body" type="textarea" :rows="2" placeholder='{"key": "value"}' />
</el-form-item>
<el-form-item v-else label="请求体(预设模板)">
<div class="preset-body-view">
<pre>{{ presetBodyView }}</pre>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="需要登录认证">
<el-switch v-model="form.authRequired" :disabled="!!form.presetId" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="请求需要签名">
<el-switch v-model="form.signRequest" :disabled="!!form.presetId" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item v-if="form.authRequired" label="账号" prop="accountKey">
<el-select v-model="form.accountKey" style="width: 100%" :disabled="!!form.presetId">
<el-option label="超级管理员" value="superadmin" />
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="form.authRequired" :gutter="16">
<el-col :span="12">
<el-form-item label="登录用户名">
<el-input v-model="form.loginUsername" placeholder="自定义登录用户名,留空使用默认" clearable :disabled="!!form.presetId" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="登录密码">
<el-input v-model="form.loginPassword" type="password" show-password placeholder="自定义登录密码,留空使用默认" clearable :disabled="!!form.presetId" />
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="!form.scenarioType" label="任务类型">
<el-form-item v-if="!form.scenarioType">
<template #label>
任务类型
<FieldTip content="单接口:只压测一个接口;多步骤事务:按顺序执行多个步骤,前序响应可被后续步骤引用(更接近真实业务链路)" />
</template>
<el-radio-group v-model="form.taskType">
<el-radio value="single">单接口(Single Request)</el-radio>
<el-radio value="transaction">多步骤事务(Transaction)</el-radio>
<el-radio value="single">单接口</el-radio>
<el-radio value="transaction">多步骤事务</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-else label="任务类型">
<el-form-item v-else>
<template #label>
任务类型
<FieldTip content="场景模式下仅支持单接口" />
</template>
<el-tag size="small">{{ scenarioLabel(form.scenarioType) }}</el-tag>
<span style="color: #909399; font-size: 12px; margin-left: 8px;">场景模式下仅支持单接口</span>
</el-form-item>
<!-- 多步骤事务:步骤列表编辑器 -->
<template v-if="form.taskType === 'transaction'">
<el-divider content-position="left">事务步骤 Transaction Steps</el-divider>
<el-divider content-position="left">事务步骤</el-divider>
<div class="txn-tip">
按顺序执行多个步骤,前序步骤捕获的响应字段可用 <code>{{ tplVar }}</code> 在后续步骤的 URL / Body 中引用。
每个虚拟用户(并发)独立维护共享上下文,步骤间不互相干扰。
......@@ -474,7 +490,7 @@
v-model="step.headersText"
type="textarea"
:rows="2"
placeholder='请求头(JSON),可选,如 {"Content-Type": "application/json"}'
placeholder='请求头(JSON),可选,如 {"Content-Type": "application/json"}'
/>
</el-col>
<el-col :span="24">
......@@ -482,7 +498,7 @@
v-model="step.bodyText"
type="textarea"
:rows="3"
placeholder='请求体(JSON),可选,支持 {{captured.key}} 变量,如 {"token": "{{captured.token}}"}'
placeholder='请求体(JSON),可选,支持 {{captured.key}} 变量,如 {"token": "{{captured.token}}"}'
/>
</el-col>
</el-row>
......@@ -500,7 +516,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可用捕获变量提示">
<el-form-item label="可用捕获变量">
<div class="capture-hint">
<template v-if="capturedKeys(sIdx).length">
<el-tag
......@@ -511,7 +527,7 @@
@click="insertCapturedVar(sIdx, k)"
>{{ capturedToken(k) }}</el-tag>
</template>
<span v-else style="color: #909399; font-size: 12px;">暂无前置步骤捕获的变量,点击上方步骤的捕获规则可生成</span>
<span v-else style="color: #909399; font-size: 12px;">暂无前置步骤捕获的变量</span>
</div>
</el-form-item>
</el-col>
......@@ -521,10 +537,10 @@
</template>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述(可选)" />
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="任务描述,可选" />
</el-form-item>
<el-collapse v-if="!form.presetId && form.taskType === 'single'">
<el-collapse v-if="form.taskType === 'single'">
<el-collapse-item name="uniqueFields" title="唯一性字段配置(可选)">
<div class="unique-field-tip">
压测并发下部分业务字段(如模板名称 templateName)不允许重复。配置后每个请求会自动生成唯一值:
......@@ -603,12 +619,13 @@
* 功能:CRUD 操作、执行/停止任务、跳转到监控/报告
*/
import { ref, onMounted, reactive, computed } from 'vue'
import { ref, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus, Refresh, Upload, Delete, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listPresets, getPreset, parseCurl, listProjects } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, ApiPresetListItem, ApiPreset, CurlParseResult, UniqueFieldRule, PerformanceProject, PerfScenarioType, LoopType, ErrorAction, ThinkTimeDistribution } from '@/types/performance'
import { listTasks, getTask, createTask, updateTask, deleteTask, runTask, stopTask, listProjects, parseCurl } from '@/api/performance'
import type { PerformanceTask, PerformanceTaskListItem, PerformanceTaskCreate, CurlParseResult, UniqueFieldRule, PerformanceProject, PerfScenarioType, LoopType, ErrorAction, ThinkTimeDistribution } from '@/types/performance'
import FieldTip from '@/components/FieldTip.vue'
const router = useRouter()
......@@ -629,10 +646,6 @@ const saving = ref(false)
const runningId = ref('')
const formRef = ref()
// 预设相关
const presets = ref<ApiPresetListItem[]>([])
const selectedPreset = ref<ApiPreset | null>(null)
// curl 导入相关
const curlDialogVisible = ref(false)
const curlCommand = ref('')
......@@ -646,7 +659,6 @@ interface TaskForm {
method: string
headers: string
body: string
bodyTemplate: string | null
mode: 'concurrent' | 'qps' | 'step'
concurrency: number
qpsTarget: number | null
......@@ -654,13 +666,7 @@ interface TaskForm {
rampUp: number | null
stepConcurrency: number | null
stepDuration: number | null
authRequired: boolean
accountKey: string | null
loginUsername: string
loginPassword: string
signRequest: boolean
assertions: any[]
presetId: string
uniqueFields: UniqueFieldRule[]
projectId: string
/** 任务类型:单接口 / 多步骤事务 */
......@@ -735,7 +741,6 @@ const defaultForm = (): TaskForm => ({
method: 'GET',
headers: '',
body: '',
bodyTemplate: null,
mode: 'concurrent',
concurrency: 10,
qpsTarget: null,
......@@ -743,13 +748,7 @@ const defaultForm = (): TaskForm => ({
rampUp: null,
stepConcurrency: null,
stepDuration: null,
authRequired: true,
accountKey: 'admin',
loginUsername: '',
loginPassword: '',
signRequest: false,
assertions: [],
presetId: '',
uniqueFields: [],
projectId: '',
taskType: 'single',
......@@ -776,16 +775,6 @@ const defaultForm = (): TaskForm => ({
const form = reactive<TaskForm>(defaultForm())
/** 预设请求体只读展示 */
const presetBodyView = computed(() => {
const p = selectedPreset.value
if (!p) return ''
if (p.bodyTemplate) {
return JSON.stringify(p.bodyTemplate, null, 2)
}
return p.body != null ? JSON.stringify(p.body, null, 2) : ''
})
const formRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
targetUrl: [{ required: true, message: '请输入目标 URL', trigger: 'blur' }],
......@@ -829,9 +818,7 @@ function projectName(id: string | null) {
/** 打开新建弹窗 */
async function openCreateDialog() {
editingTask.value = null
selectedPreset.value = null
Object.assign(form, defaultForm())
await loadPresets()
await loadProjects()
dialogVisible.value = true
}
......@@ -849,38 +836,13 @@ async function openEditDialog(task: PerformanceTaskListItem) {
}
editingTask.value = fullTask
// 加载预设列表
await loadPresets()
await loadProjects()
// 如果任务有 presetId,找到对应的预设并回填
const presetId = fullTask.presetId || ''
if (presetId) {
const found = presets.value.find(p => p.id === presetId)
if (found) {
// 获取完整预设数据
try {
const preset = await getPreset(presetId)
selectedPreset.value = preset
} catch {
selectedPreset.value = null
}
} else {
// 预设已被删除:清除关联,字段保持可编辑
selectedPreset.value = null
}
} else {
selectedPreset.value = null
}
Object.assign(form, {
name: fullTask.name,
description: fullTask.description,
targetUrl: fullTask.targetUrl,
method: fullTask.method,
headers: fullTask.headers ? JSON.stringify(fullTask.headers, null, 2) : '',
body: fullTask.body ? (typeof fullTask.body === 'string' ? fullTask.body : JSON.stringify(fullTask.body, null, 2)) : '',
bodyTemplate: fullTask.bodyTemplate,
mode: fullTask.mode,
concurrency: fullTask.concurrency,
qpsTarget: fullTask.qpsTarget,
......@@ -888,13 +850,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
rampUp: fullTask.rampUp,
stepConcurrency: Array.isArray(fullTask.stepConcurrency) ? Math.max(...fullTask.stepConcurrency) : fullTask.stepConcurrency,
stepDuration: fullTask.stepDuration,
authRequired: fullTask.authRequired,
accountKey: fullTask.accountKey,
loginUsername: fullTask.loginUsername || '',
loginPassword: fullTask.loginPassword || '',
signRequest: fullTask.signRequest,
assertions: fullTask.assertions || [],
presetId: presetId && selectedPreset.value ? presetId : '',
uniqueFields: (fullTask.uniqueFields || []).map(r => ({ ...r })),
projectId: fullTask.projectId || '',
taskType: fullTask.steps && fullTask.steps.length ? 'transaction' : 'single',
......@@ -952,11 +908,6 @@ function onModeChange(mode: string) {
/** 场景类型切换 */
function onScenarioTypeChange(val: any) {
const type = val || null
// burst/endurance 场景与单接口预设互斥:切换场景时解除预设关联
if (type && form.presetId) {
selectedPreset.value = null
form.presetId = ''
}
// 场景模式强制使用并发模式,自动切换
if (type) {
form.mode = 'concurrent'
......@@ -994,67 +945,6 @@ function moveScenarioApi(index: number, dir: number) {
arr[target] = tmp
}
/** 加载预设列表 */
async function loadPresets() {
try {
const res = await listPresets({ pageSize: 200 })
presets.value = res.items || []
} catch {
presets.value = []
}
}
/** 预设切换:填充接口字段或清空恢复到手动 */
async function onPresetChange(presetId: string) {
if (!presetId) {
// 清空预设,恢复到手动填写
selectedPreset.value = null
form.method = 'GET'
form.targetUrl = ''
form.headers = ''
form.body = ''
form.bodyTemplate = null
form.authRequired = true
form.signRequest = false
form.accountKey = 'admin'
form.presetId = ''
// 任务类型与 steps 保持不动,由用户自行切换
return
}
// 获取完整预设数据
try {
const preset = await getPreset(presetId) as ApiPreset
selectedPreset.value = preset
// 预设只用于单接口,切换预设时强制回到单接口模式
form.taskType = 'single'
form.steps = []
// 填充接口字段
form.method = preset.method
form.targetUrl = preset.targetUrl
form.headers = preset.headers ? JSON.stringify(preset.headers, null, 2) : ''
if (preset.bodyTemplate) {
form.body = ''
form.bodyTemplate = JSON.stringify(preset.bodyTemplate, null, 2)
} else {
form.bodyTemplate = null
form.body = preset.body != null ? JSON.stringify(preset.body, null, 2) : ''
}
form.authRequired = preset.authRequired
form.signRequest = preset.signRequest
form.accountKey = preset.accountKey || 'superadmin'
form.loginUsername = preset.loginUsername || ''
form.loginPassword = preset.loginPassword || ''
form.presetId = preset.id
} catch (e: any) {
ElMessage.error('获取预设详情失败: ' + (e.message || ''))
selectedPreset.value = null
form.presetId = ''
}
}
/** 打开 curl 导入对话框 */
function openCurlDialog() {
curlCommand.value = ''
......@@ -1086,10 +976,6 @@ async function handleCurlParse() {
/** 将解析结果回填到任务表单 */
function applyCurlResult(result: CurlParseResult) {
// 解除预设关联(回填值与预设无关,恢复接口字段可编辑)
selectedPreset.value = null
form.presetId = ''
form.method = result.method || 'GET'
form.targetUrl = result.targetUrl || ''
......@@ -1105,10 +991,6 @@ function applyCurlResult(result: CurlParseResult) {
form.body = ''
}
form.authRequired = !!result.authRequired
form.signRequest = !!result.signRequest
if (!form.authRequired) form.accountKey = 'admin'
// 不支持参数提示(不阻断回填)
curlErrors.value = result.errors || []
if (curlErrors.value.length) {
......@@ -1119,12 +1001,6 @@ function applyCurlResult(result: CurlParseResult) {
}
}
/** 账号标签 */
function accountKeyLabel(key: string) {
const map: Record<string, string> = { superadmin: '超级管理员', admin: '管理员', user: '普通用户' }
return map[key] || key
}
/** 添加唯一性字段规则 */
function addUniqueField() {
form.uniqueFields.push({ field: '', strategy: 'suffix', template: null })
......@@ -1176,12 +1052,6 @@ function insertCapturedVar(sIdx: number, key: string) {
}
}
/** 方法标签颜色 */
function methodTagType(method: string) {
const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger' }
return map[method] || 'info'
}
/** 提取后端校验错误的可读信息(Pydantic detail 可能是数组) */
function extractApiError(e: any): string {
const detail = e?.response?.data?.detail
......@@ -1224,9 +1094,6 @@ async function handleSave() {
method: form.method,
headers: form.headers ? JSON.parse(form.headers) : null,
body: form.body ? JSON.parse(form.body) : null,
bodyTemplate: form.bodyTemplate
? (typeof form.bodyTemplate === 'string' ? JSON.parse(form.bodyTemplate) : form.bodyTemplate)
: null,
mode: form.mode,
concurrency: form.concurrency,
qpsTarget: form.mode === 'qps' ? form.qpsTarget : null,
......@@ -1239,13 +1106,7 @@ async function handleSave() {
: [form.stepConcurrency]
: null,
stepDuration: form.mode === 'step' ? (form.stepDuration || 30) : null,
authRequired: form.authRequired,
accountKey: form.authRequired ? form.accountKey : null,
loginUsername: form.loginUsername || null,
loginPassword: form.loginPassword || null,
signRequest: form.signRequest,
assertions: [],
presetId: form.presetId || null,
uniqueFields: form.uniqueFields.length > 0 ? form.uniqueFields : null,
steps: form.taskType === 'transaction' ? form.steps.map(s => ({
name: s.name,
......@@ -1402,57 +1263,6 @@ onMounted(() => {
margin-top: 16px;
}
/* 预设摘要卡片 */
.preset-summary {
margin-bottom: 16px;
background: #fafafa;
}
.preset-summary-header {
display: flex;
align-items: center;
gap: 8px;
}
.preset-summary-title {
font-weight: 600;
font-size: 14px;
}
.preset-summary-body {
font-size: 13px;
line-height: 1.8;
position: relative;
padding-right: 120px;
}
.preset-summary-body code {
word-break: break-all;
font-size: 12px;
}
.preset-summary-clear {
position: absolute;
right: 0;
top: 0;
}
.preset-body-view {
width: 100%;
max-height: 90px;
overflow: auto;
background: #f5f7fa;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 6px 10px;
font-size: 12px;
}
.preset-body-view pre {
margin: 0;
color: #606266;
}
/* 唯一性字段配置 */
.unique-field-tip {
font-size: 12px;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论