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

feat(perf-report): 报告导出排版质量优化 + 导出文件名使用任务名

- FR-1 图表尺寸控制:图表 img width=700 解决 Word 溢出,保留 pixelRatio:2 高清
- FR-2 指标卡片 Word 兼容:flexbox 改 table 网格布局(核心/执行机/目标机/MySQL)
- FR-3 表格列宽比例:5 张表全部 colgroup 控制列宽
- FR-4 专业排版:h1 21pt 主题色底边线 / h2 分隔线 / 表头隔行变色 / mso-* Word 兼容
- FR-5 分页控制:page-break-inside/after avoid + 快照表前强制分页
- FR-6 合并报告同步:ProjectReport 同步全部排版优化
- 导出文件名:单任务用任务名、合并报告用项目名(sanitizeFilename 清洗非法字符)
- 新增 PRD 文档:报告导出排版质量优化需求文档 + 计划执行文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 8f417389
......@@ -2,10 +2,162 @@
> **生成时间**: 2026-08-25
> **当前分支**: `platform-auto-test`
> **最近提交**: `6c925627` docs(performance): 请求详情/报告导出 PRD + 部署问题处理文档 + HANDOFF 会话进度
> **会话窗口**: 性能测试 — 请求详情接口名显示修复 + URL 去前缀
> **状态**: ✅ 接口名显示修复已确认 + E2E 数据链路验证通过;URL 去前缀体验待实现
> **会话窗口(上一窗口)**: 部署后 3 个问题排查修复 + 端到端自测通过(已提交推送)
> **最近提交**: `8f417389` fix(perf-report): 补全报告导出缺失模块与图表 + 接口名URL去前缀
> **会话窗口**: 性能测试 — 报告导出排版质量优化(FR-1~FR-6 已实现 + 部署 5.60 + e2e 验证通过)
> **状态**: ✅ 排版优化全部验收标准通过(图表不溢出/指标卡 table 布局/列宽比例/专业排版/分页控制/合并报告同步)
> **会话窗口(上一窗口)**: 报告导出内容补全 + 接口名 URL 去前缀(commit `8f417389` 已部署)
---
## ⚡ 最新会话更新(2026-08-26)— 导出文件名使用任务名称 + 部署 5.60 ✅
### A. 会话背景
用户要求:导出的报告文件名称直接用任务名称(此前 Word/PDF/JSON 文件名是 `perf-report-{taskId}` 这类平台 ID,用户不友好)。
### B. 本会话完成项(3 个前端文件,均未提交)
| # | 文件 | 改动 |
|---|------|------|
| 1 | `frontend/src/utils/exportReport.ts` | ① 新增 `sanitizeFilename()`:清洗 Windows/macOS 非法字符(`\ / : * ? " < > \|``_`),防止任务名含特殊字符导致下载失败;② `exportWord`/`exportJson` 下载文件名统一过清洗;③ `exportPdf()` 新增可选 `filename` 参数——设置打印窗口 `document.title`,浏览器「另存为 PDF」的默认文件名即由此而来 |
| 2 | `frontend/src/views/performance/ReportPanel.vue`(单任务报告) | Word / PDF / JSON 文件名:`perf-report-{taskId}`**`{taskName}`**(如 `登录接口瞬时并发压测(Burst-200用户-CSV多账号).doc`) |
| 3 | `frontend/src/views/performance/ProjectReport.vue`(合并报告) | 合并报告无单一任务名,用**项目名称**做文件名(兜底 `report.id`);Word 文档标题也从 `合并报告 - {report.id}` 改为 `合并报告 - {项目名}` |
> 文档内标题(`性能测试报告 - 任务名`)保持不变,仅影响下载文件名。
### C. 部署 5.60 ✅
| 步骤 | 结果 |
|------|------|
| 前端 `npm run build` | ✅ 通过(2367 modules,33.15s,vue-tsc 类型检查全过) |
| 上传前端 dist | ✅ `deploy_frontend_only.py` 上传 95 个文件 → `/data/third_party/plat-auto-test/frontend/dist/` |
| 容器重启 | ✅ `docker compose restart app`,health `{"status":"healthy"}` |
| 文件生效验证 | ✅ 新 hash 文件就位:`exportReport-BzkY8IjD.js` / `ReportPanel-DhJvJXIv.js` / `ProjectReport-ieLpeasq.js` |
### D. 遗留待办(交接给下一窗口)
| # | 任务 | 说明 |
|---|------|------|
| 1 | **git commit + push(P0)** | 本次 3 文件 + 上一窗口排版优化 3 文件(`exportReport.ts` / `ReportPanel.vue` / `ProjectReport.vue`)+ 2 份 PRD 文档(`_PRD_报告导出排版质量优化.md` + `_计划执行.md`)均未提交,可用 `/GitCommit` |
| 2 | 导出文件名效果验证(可选) | 5.60 打开报告页 → 导出 Word/PDF/JSON,确认下载文件名 = 任务名称(单任务)/ 项目名称(合并报告) |
| 3 | 遗留未提交文件 | `backend/data/test_platform.db`(按约定不提交);`tmp/acct/dl/*.doc` 验证下载文件可清理 |
---
## ⚡ 最新会话更新(2026-08-26)— 报告导出排版质量优化实现 + 部署 5.60 + 验证全通过
### A. 会话背景
上一窗口(commit `8f417389`)已补全报告导出的全部内容模块与图表,但用户反馈导出的 Word/PDF 文档排版仍存在明显问题:图表宽度溢出(`pixelRatio: 2` 导出 1600px+ 超 A4 页宽 794px)、指标卡片 flexbox 在 Word 中渲染错乱、表格列宽不均衡、跨页断裂、视觉粗糙。本次会话按 PRD `_PRD_报告导出排版质量优化.md` 实施 FR-1~FR-6。
### B. 本会话完成项(3 前端文件 + 2 份 PRD 文档,**均未提交**)
| # | 需求 | 实现 |
|---|------|------|
| 1 | **FR-1 图表尺寸控制** | `ReportPanel.vue` / `ProjectReport.vue` 全部图表 `<img>` 增加 `width="700"`(Word 最可靠宽度控制),保留 `max-width:100%` 用于 PDF 打印,`pixelRatio:2` 高清未降级 |
| 2 | **FR-2 指标卡片 Word 兼容布局** | `exportReport.ts` 新增 `buildMetricGrid()`:flexbox → `<table>` 网格(每行 4 个,`cellspacing=8`,空单元格补齐保持列宽),核心指标 10 / 执行机 6 / 目标机 6 / MySQL 10 |
| 3 | **FR-3 表格列宽比例** | 5 张表全部 `<colgroup>` 控制:摘要 20/80、分位数 30/70、状态码 20/30/50、Java 进程 25+12.5×6、快照 15/20/20/20/25 |
| 4 | **FR-4 专业排版** | `buildExportHtml()` CSS 全面升级:h1 21pt+3px 主题色底边线 / h2 14pt+浅灰分隔线 / 正文 10.5pt、表头 #f0f2f5+隔行 #fafbfc、指标卡 #f8f9fa 值 15pt 主题色、页脚分隔线居中、Word 兼容 `mso-*` 样式 + BOM |
| 5 | **FR-5 分页控制** | `@media print`:图表/表格/标题 `page-break-inside/after: avoid`,快照长表前 `.section-break` 强制分页 |
| 6 | **FR-6 合并报告同步** | `ProjectReport.vue` 同步全部排版优化(buildMetricGrid 核心指标 14 个 + 任务明细表 colgroup 20%/均分) |
**改动文件**`frontend/src/utils/exportReport.ts``frontend/src/views/performance/ReportPanel.vue``frontend/src/views/performance/ProjectReport.vue`(工作区未提交)+ 新增 PRD/计划执行文档(未跟踪)。
### C. 部署 5.60 ✅
| 步骤 | 结果 |
|------|------|
| 前端 `npm run build` | ✅ 通过 |
| 上传前端 dist | ✅ `deploy_frontend_only.py` 上传 95 个文件 → `/data/third_party/plat-auto-test/frontend/dist/` |
| 容器重启 | ✅ `docker restart plat-auto-test-app`,health 正常,前端 HTTP 200 + 新 hash 文件生效 |
### D. 后端验证(真实下载导出文件解析)✅
用 Playwright 从 http://192.168.5.60 报告页(任务 `perf_4fb439c759a94f54b3c64829714564fa`)实际点击「导出 Word」下载 `.doc` 文件(648KB)并解析,同时验证 PDF 打印弹窗:
| 验收项 | 结果 |
|--------|------|
| 8 张图表全部 `width="700"` | ✅ 0 个缺 width 属性 |
| base64 图表数据完整(57K~101K) | ✅ `pixelRatio:2` 高清保留 |
| 指标卡 table:核心 10 / 执行机 6 / 目标机 6 / MySQL 10 | ✅ 4 个 metric-grid-table,42 个 metric-item |
| colgroup 5 张表列宽比例全部正确 | ✅ 20/80 · 30/70 · 20/30/50 · 25+12.5×6 · 15/20/20/20/25 |
| 快照表前 `section-break` 强制分页 | ✅ 文档结构 h2 层级 17 个完整 |
| 专业排版:h1 21pt + 底边线 / h2 14pt / 正文 10.5pt / 表头 + 交替行(class="alt")| ✅ CSS 全部命中 |
| Word 兼容:BOM + `xmlns:o`/`xmlns:w` + `mso-*` | ✅ |
| 页脚时间戳 | ✅ 2026/8/26 09:43:59 |
| PDF 弹窗:8 图 `complete=true` + `naturalWidth`>0 | ✅ metricTables=4 colgroups=5 sectionBreaks=1 hasPrintCss=true |
**6 项验收标准(FR-1~FR-6)全部通过。**
### E. 遗留待办(交接给下一窗口)
| # | 任务 | 说明 |
|---|------|------|
| 1 | **git commit + push(P0)** | 3 个前端文件(exportReport.ts / ReportPanel.vue / ProjectReport.vue)+ 2 份 PRD 文档(`_PRD_报告导出排版质量优化.md` + `_计划执行.md`)未提交,可用 `/GitCommit` |
| 2 | 可选优化(P2) | 快照表是文档最后 section 且带 `section-break`,若前页内容恰好到页尾可能产生一页较空;可评估给最后 section 去掉强制分页或改 `keep-together` |
| 3 | 遗留未提交文件 | `backend/data/test_platform.db`(按约定不提交);`tmp/acct/dl/*.doc` 验证下载文件可清理;其他窗口文档(设备模拟/功能测试)不受影响 |
---
## ⚡ 最新会话更新(2026-08-25 收尾)— 报告导出质量优化实现 + 部署 5.60
### A. 会话背景
用户反馈「报告导出的文件内容怎么都是截图」,经代码审查确认根因不是"只有截图",而是 `buildReportHtml()` 与页面渲染**严重不同步**:新增页面模块(资源监控、MySQL、事务瀑布等)时只更新了 Vue 模板,漏掉导出函数,导致导出 HTML 大量文本内容缺失,图表图片视觉上占据主导。
### B. 本会话完成项(3 大块,全部已提交 `8f417389` 并部署 5.60)
| # | 任务 | 说明 |
|---|------|------|
| 1 | **报告导出内容补全(ReportPanel)** | `buildReportHtml()` 补全 6 个缺失模块:执行机资源指标卡片 / 目标机资源指标卡片 / Java 进程统计表 / MySQL 监控指标卡片 / 事务步骤明细表 / 接口级统计明细表 |
| 2 | **报告导出图表补全(ReportPanel)** | 补全 5 个缺失图表:执行机资源趋势图 / 网络趋势图 / 目标机资源趋势图 / Java 进程 CPU 趋势图 / 事务瀑布图(均 ECharts base64) |
| 3 | **导出格式优化(exportReport.ts)** | `waitForImages()` 动态等待图片加载(5s 超时兜底)替代 500ms 固定延时;`buildExportHtml()` 新增 Word 兼容 `mso-` 样式 + 打印分页控制(`page-break-inside: avoid` / `.section-break`) |
| 4 | **ProjectReport 导出补全** | 导出指标卡补 min/max RT + 收发字节(合并报告版,不含图表) |
| 5 | **接口名 URL 去前缀** | 后端 `_strip_url_prefix()``_build_request_detail()` 生成 api_name 时存去前缀路径)+ 前端 `displayApiName()` 正则兜底(`https?://[^/]+` → 空) |
### C. PRD / 计划执行文档(已新建,随 commit 提交)
| 文档 | 路径 |
|------|------|
| 报告导出质量优化 PRD | `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化.md`(FR-1~FR-5,P0:内容补全 + 图表补全) |
| 报告导出质量优化计划执行 | `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化_计划执行.md`(5 Phase,ReportPanel 补全 → 图表补全 → ProjectReport 补全 → 格式优化 → 验证部署) |
### D. 提交记录(`8f417389`,已推送 origin/platform-auto-test)
```
fix(perf-report): 补全报告导出缺失模块与图表 + 接口名URL去前缀
- buildReportHtml() 补全6个缺失模块(执行机资源/目标机资源/Java进程/MySQL/事务步骤/接口统计)
- buildReportHtml() 补全5个缺失图表(网络趋势/目标机资源/Java进程CPU/瀑布图)
- exportPdf() 图片加载动态等待(5s超时兜底),避免PDF空白图片
- buildExportHtml() 新增Word兼容mso-样式 + 打印分页控制
- ProjectReport 导出补全 min/max RT + 收发字节指标
- 接口名展示:后端 _strip_url_prefix() + 前端 displayApiName() 兜底去前缀
- 新增 PRD 文档:报告导出质量优化需求文档 + 计划执行文档
```
**变更文件(8 个,+895/-29)**`backend/app/executors/performance_executor.py``frontend/src/utils/exportReport.ts``frontend/src/views/performance/ReportPanel.vue``frontend/src/views/performance/ProjectReport.vue``frontend/src/views/performance/RequestDetailPanel.vue``frontend/package-lock.json`、2 份新增 PRD/计划文档。
### E. 部署 5.60 记录 ✅
| 步骤 | 结果 |
|------|------|
| 前端 `npm run build` | ✅ 通过(33.52s,2367 modules) |
| 上传前端 dist | ✅ 95 个文件 → `/data/third_party/plat-auto-test/frontend/dist/` |
| 上传后端 | ✅ `performance_executor.py``/data/third_party/plat-auto-test/backend/app/executors/` |
| 容器重启 | ✅ `docker restart plat-auto-test-app` |
| 健康检查 | ✅ HTTP 200(约 10s) |
| 访问地址 | http://nat.ubainsyun.com:25124(刷新即生效) |
> 部署方式说明:前端 dist 为 volume 挂载热更新(无需重启);后端 Python 文件 volume 挂载 + 容器重启生效。本次仅前端 + 1 个后端执行器文件,接口名去前缀需**重新执行任务**才对新数据生效(历史记录不受影响)。
### F. 遗留待办(交接给下一窗口)
| # | 任务 | 说明 |
|---|------|------|
| 1 | **报告导出端到端验收** | 5.60 打开报告页 → 导出 Word/PDF → 核对 6 个补全模块 + 5 个图表与页面一致;Word 用 WPS/Office 打开验证表格不串列、图片不溢出 |
| 2 | **接口名去前缀验证** | 重新执行一次压测任务 → 请求详情察看结果树接口名不再带 `https://IP` 前缀(需新执行才生效) |
| 3 | 遗留未提交文件 | `backend/data/test_platform.db` 工作区变更(按约定不提交);`backend/scripts/probe_44_202.py``frontend/public/` 临时文件确认是否清理 |
| 4 | FR-5 图片加载提示(P2 可选) | 计划文档已列:PDF 导出加载期间显示 loading 提示,未实现,非必须 |
---
......
# 需求文档:报告导出排版质量优化(专业级排版与数据展示)
> **生成时间**:2026-08-25
> **关联文档**:`_PRD_报告导出质量优化.md`(内容补全)、`_PRD_请求详情查看与报告导出优化.md`
> **版本**:v1.0
> **优先级**:P0(排版质量)
---
## 1. 背景与问题
### 1.1 问题现象
上一轮优化已补全报告导出的全部内容模块和图表,但用户反馈导出的 Word/PDF 文档在排版上仍存在明显问题:
1. **图表宽度溢出**:图表图片(ECharts 以 `pixelRatio: 2` 导出)在 Word 中按实际像素尺寸显示(1600px+),超出 A4 页面宽度(约 794px),用户需手动缩放才能查看完整图表
2. **数据排版不专业**:指标卡片、表格、段落间距等缺乏专业排版规范,整体视觉效果不够严谨
3. **Word 兼容性差**:flexbox 布局在 Word 中渲染异常,表格列宽未被比例控制,长内容撑开单元格
### 1.2 根因分析
| # | 问题 | 根因 |
|---|------|------|
| 1 | 图表宽度溢出 | ECharts `getDataURL()``pixelRatio: 2` 导出 1600px 图片,Word 按图片实际像素渲染,CSS `max-width: 100%` 在 Word 中无效 |
| 2 | 指标卡片错乱 | 使用 CSS flexbox 布局,Word 对 flexbox 支持极差,卡片可能堆叠/错位 |
| 3 | 表格列宽不均衡 | 未设置比例列宽,内容较长的列撑开整个表格 |
| 4 | 跨页断裂 | 缺少精细的 `page-break` 控制,图表/表格可能被跨页截断 |
| 5 | 视觉效果粗糙 | 缺乏专业报告应有的间距、层次、对齐、装饰线等排版元素 |
---
## 2. 需求范围
### 2.1 目标
将性能测试报告(Word/PDF 导出)提升至**专业级报告排版水平**,确保:
- 所有图表在 Word/PDF 中均完整可见,不溢出页宽
- 数据展示层次分明、间距合理、配色统一
- 表格列宽按比例分配,内容不溢出
- 分页合理,无尴尬跨页截断
- 整体风格专业、严谨、可读性高
### 2.2 FR-1: 图表尺寸控制(P0)
| 属性 | 说明 |
|------|------|
| 优先级 | P0 |
| 描述 | 所有图表图片在导出文档中统一约束宽度,不超出页面宽度 |
**实现方案**
| 措施 | 说明 |
|------|------|
| 1.1 `<img>` 宽度属性 | 所有图表 `<img>` 标签增加 `width="700"` 属性(Word 最可靠的宽度控制方式),保留 `max-width: 100%` 用于 PDF 打印 |
| 1.2 图片容器控制 | 图表外层 `<div>``max-width: 100%; overflow: hidden;` 兜底 |
| 1.3 保持高分辨率 | 保留 `pixelRatio: 2` 导出,确保高 DPI 屏幕下打印清晰,仅通过 HTML 属性约束显示宽度 |
**涉及图表**(共 9 个):TPS 趋势、响应时间趋势、延迟分解趋势、带宽趋势、执行机资源趋势、网络趋势、目标机资源趋势、Java 进程 CPU 趋势、事务瀑布图
### 2.3 FR-2: 指标卡片 Word 兼容布局(P0)
| 属性 | 说明 |
|------|------|
| 优先级 | P0 |
| 描述 | 指标卡片区域改用 Word 兼容的布局方式,替代 flexbox |
**实现方案**
| 措施 | 说明 |
|------|------|
| 2.1 表格布局 | 指标卡片改用 `<table>` 布局,每行 3~4 个指标,Word 渲染最稳定 |
| 2.2 内联样式 | 所有样式使用内联 `style=""`,避免类选择器在 Word 中不生效 |
| 2.3 响应式行数 | 根据指标数量自动分 2~3 行显示(每行 3~4 个卡片) |
**涉及区域**
- 核心指标区(10 个指标:总请求数、成功/失败数、错误率、TPS、峰值 TPS、平均/ P95 响应时间、标准差、Apdex)
- 执行机资源指标卡片(6 个指标)
- 目标机资源指标卡片(6 个指标)
- MySQL 监控指标卡片(10 个指标,分两行)
### 2.4 FR-3: 表格列宽比例控制(P0)
| 属性 | 说明 |
|------|------|
| 优先级 | P0 |
| 描述 | 所有表格按内容类型设置比例列宽,避免列被内容撑开 |
**列宽分配方案**
| 表格 | 列宽比例 |
|------|---------|
| 执行摘要表 | 标签列 20% / 值列 80% |
| 响应时间分位数表 | 分位数列 30% / 值列 70% |
| 状态码分布表 | 状态码列 25% / 数量列 25% / 占比列 50% |
| 快照数据表 | 时间列 15% / TPS 20% / 平均响应 20% / P95 20% / 错误率 25% |
| Java 进程统计表 | 进程名 25% / 其余 6 列均分 75% |
| 事务步骤明细表 | 步骤名 22% / 其余 7 列均分 78% |
| 接口级统计明细表 | 接口名 20% / 其余 8 列均分 80% |
| 各任务明细表(合并报告) | 任务名 20% / 其余 7 列均分 80% |
**实现方式**:使用 `<colgroup><col style="width:XX%">...</colgroup>``<th width="XX%">`
### 2.5 FR-4: 专业排版样式(P1)
| 属性 | 说明 |
|------|------|
| 优先级 | P1 |
| 描述 | 全面提升报告 CSS 样式,达到专业级排版标准 |
**样式优化项**
| 类别 | 优化内容 |
|------|---------|
| **字体** | 标题层级的字号/字重/颜色层次分明;正文统一 10.5pt;表格表头 9pt 加粗;表格内容 9pt |
| **间距** | 段落间距统一 6pt;章节间距 18pt;表格前后间距 12pt |
| **颜色** | 主标题 #1a1a1a / 章节标题 #2c3e50 / 正文 #333333 / 辅助文字 #666666 |
| **表格** | 表头背景 #f0f2f5;隔行交替色 #fafbfc;边框 #d0d5dd 细线 0.5pt |
| **装饰** | 主标题下加 3px 主题色底边线;章节标题下加 1px 浅灰分隔线 |
| **指标卡片** | 背景 #f8f9fa;圆角 6px(Word 中忽略);值 16pt 加粗主题色;标签 8pt 浅灰 |
| **页脚** | 居中浅灰文字,与正文间加 1px 分隔线 |
### 2.6 FR-5: 分页控制优化(P1)
| 属性 | 说明 |
|------|------|
| 优先级 | P1 |
| 描述 | 优化打印/导出时的分页位置,避免不合理的跨页截断 |
**控制规则**
| 规则 | 适用元素 |
|------|---------|
| `page-break-inside: avoid` | 所有 `section`、图表容器、表格 |
| `page-break-after: avoid` | 所有 `h1``h2``h3` 标题 |
| `page-break-before: always` | 快照数据表(长表前强制分页) |
| 自动分页 | 核心指标卡片区、图表区、资源汇总区各占一页 |
### 2.7 FR-6: 合并报告(ProjectReport)同步优化(P1)
| 属性 | 说明 |
|------|------|
| 优先级 | P1 |
| 描述 | 将 FR-1~FR-5 的排版优化同步应用到合并报告导出 |
**注意**`ProjectReportResponse` 仅包含 `summary``taskSummaries`,不包含单报告的资源/MySQL/事务等字段,故仅同步排版样式(不涉及内容补全)。
---
## 3. 非功能需求
| 类型 | 要求 |
|------|------|
| 兼容性 | 导出 .doc 在 WPS 2019+ / Microsoft Word 2016+ 中打开,图表不溢出、表格不串列、字体不丢失 |
| 性能 | 导出 PDF 触发打印前等待时间 ≤ 3s;导出 Word 点击即下载(文件体积 ≤ 5MB) |
| 可维护性 | 排版样式集中在 `buildExportHtml()` 的 CSS 中,不在各模块散落 |
| 一致性 | 报告页面的视觉层级(标题→章节→指标→图表→表格)与导出文档保持一致 |
---
## 4. 不在范围
- 导出 Excel / CSV 格式
- 后端 reportlab / python-docx 服务端渲染
- 报告自动邮件发送
- 用户自定义导出模板
- 报告对比(两次执行 diff)导出
- 导出内容补全(已在 `_PRD_报告导出质量优化.md` 中完成)
---
## 5. 影响范围
### 前端
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `frontend/src/utils/exportReport.ts` | MODIFY | `buildExportHtml()` CSS 全面升级:字体、间距、颜色、分页、Word 兼容;图表尺寸控制 |
| `frontend/src/views/performance/ReportPanel.vue` | MODIFY | `buildReportHtml()``<img>` 加 width 属性;指标卡片改 table 布局;表格加 colgroup 列宽控制 |
| `frontend/src/views/performance/ProjectReport.vue` | MODIFY | `buildReportHtml()` 同步排版优化(样式+表格列宽+分页) |
### 文档
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出排版质量优化.md` | ADD | 本需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出排版质量优化_计划执行.md` | ADD | 计划执行文档 |
---
## 6. 验收标准
### FR-1 图表尺寸控制
- [ ] Word 导出所有图表宽度 ≤ 页面宽度,无需手动缩放即可查看完整图表
- [ ] PDF 导出所有图表完整显示,不溢出页边距
- [ ] 图表保持清晰(`pixelRatio: 2` 未降级)
### FR-2 指标卡片布局
- [ ] Word 中指标卡片按网格排列(每行 3~4 个),不堆叠不错位
- [ ] 卡片内数值和标签对齐整齐
- [ ] 核心指标区、资源指标区、MySQL 区均正常渲染
### FR-3 表格列宽
- [ ] 所有表格列宽按比例分配,不因内容长短而失衡
- [ ] 长文本(如接口 URL)在单元格内自动换行,不溢出
- [ ] 窄列(如状态码、TPS 数值)不因其他列宽而异常
### FR-4 专业排版
- [ ] 标题层级分明(h1 > h2 > h3 字号/字重逐级递减)
- [ ] 章节间距合理,视觉上清晰区分各模块
- [ ] 表头背景色、隔行交替色正常渲染(Word 中至少表头背景应显示)
- [ ] 整体风格一致,无突兀的样式冲突
### FR-5 分页控制
- [ ] 图表不跨页断裂(整图在同一页内)
- [ ] 标题不单独出现在页末(标题后至少跟一行正文)
- [ ] 长表(快照表)前有分页,不从中间截断
### FR-6 合并报告
- [ ] 合并报告 Word/PDF 导出版本同样应用以上排版优化
---
## 7. 术语
| 术语 | 说明 |
|------|------|
| `pixelRatio` | ECharts 导出图片的像素倍率,2 即为 2x 高清图 |
| Word 兼容 | 特指 `application/msword` 格式的 HTML 渲染兼容性 |
| 指标卡片 | 展示关键数值的矩形区块,包含数值和标签 |
| colgroup | HTML `<colgroup>` 元素,用于定义表格列的宽度/样式 |
| 跨页断裂 | 表格/图表在分页处被截断,上半部分在上一页、下半部分在下一页 |
---
*本文件由 Claude Code 生成,用于指导报告导出排版质量优化的实施。*
\ No newline at end of file
# 报告导出排版质量优化 - 计划执行文档
> **关联 PRD**:`_PRD_报告导出排版质量优化.md`
> **文档版本**:v1.0
> **制定日期**:2026-08-25
> **执行分支**:`platform-auto-test`
> **优先级**:P0(图表宽度溢出 + 排版不专业)
---
## 一、执行目标
1. **图表尺寸控制**:所有 9 个图表图片在 Word/PDF 导出中宽度不超过 A4 页面宽度,无需手动缩放即可查看完整图表;
2. **指标卡片 Word 兼容布局**:将 flexbox 布局改为 `<table>` 网格布局,确保在 Word 中正常渲染;
3. **表格列宽比例控制**:所有表格按比例分配列宽,避免内容撑开导致排版错乱;
4. **专业排版样式**:统一字体层级、间距、颜色、装饰线,达到专业报告水准;
5. **分页控制优化**:合理控制分页位置,避免图表/表格跨页断裂。
---
## 二、实施原则
1. **双兼容**:所有样式兼顾 Word (`application/msword`) 和 PDF (`window.print`) 两种导出场景;
2. **内联优先**:关键样式使用内联 `style=""`,避免 Word 忽略类选择器;
3. **增量修改**:不改动现有导出逻辑结构,仅增强样式和布局;
4. **保持分辨率**:图表保留 `pixelRatio: 2` 高清导出,仅通过 HTML 属性约束显示宽度。
---
## 三、阶段计划
### Phase 0:文档准备
| 工作项 | 文件 | 结果 |
|--------|------|------|
| 编写需求文档 | `_PRD_报告导出排版质量优化.md` | 6 个 FR、验收标准 |
| 编写本计划 | `_PRD_报告导出排版质量优化_计划执行.md` | 5 个 Phase、变更清单、验证方式 |
---
### Phase 1:CSS 样式全面升级(exportReport.ts)
**文件**`frontend/src/utils/exportReport.ts``buildExportHtml()` 函数
#### 1.1 字体层级优化
```css
/* 标题层级 */
h1 {
font-size: 22pt; font-weight: 700; color: #1a1a1a;
border-bottom: 3px solid #409eff; padding-bottom: 10px; margin: 0 0 18pt 0;
}
h2 {
font-size: 14pt; font-weight: 600; color: #2c3e50;
border-bottom: 1px solid #e8e8e8; padding-bottom: 6px; margin: 18pt 0 12pt 0;
}
h3 {
font-size: 12pt; font-weight: 600; color: #333;
margin: 14pt 0 8pt 0;
}
/* 正文 */
body {
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
font-size: 10.5pt; color: #333; line-height: 1.7;
max-width: 100%; margin: 0 auto; padding: 20px;
}
```
#### 1.2 表格样式升级
```css
/* 表格通用 */
table {
width: 100%; border-collapse: collapse; margin: 10pt 0;
font-size: 9pt; page-break-inside: avoid;
}
/* 表头 */
th {
background: #f0f2f5; font-weight: 600; padding: 8px 10px;
text-align: left; border: 0.5pt solid #d0d5dd;
mso-border-alt: thin solid #d0d5dd;
}
/* 单元格 */
td {
padding: 6px 10px; border: 0.5pt solid #d0d5dd;
mso-border-alt: thin solid #d0d5dd;
}
/* 隔行交替色 */
tr:nth-child(even) td {
background: #fafbfc;
}
```
#### 1.3 指标卡片样式(Word 兼容)
不依赖 flexbox,改用 `display: inline-block` + 表格兜底:
```css
.metric-grid {
width: 100%; margin: 10pt 0;
}
.metric-item {
display: inline-block; width: 23%; /* 每行 4 个 */
margin: 4px 1%; padding: 10pt 0;
text-align: center; background: #f8f9fa;
border: 0.5pt solid #e8e8e8;
mso-border-alt: thin solid #e8e8e8;
page-break-inside: avoid;
}
.metric-value {
font-size: 16pt; font-weight: 700; color: #409eff;
display: block; line-height: 1.4;
}
.metric-label {
font-size: 7.5pt; color: #666;
display: block; margin-top: 2px;
}
```
#### 1.4 图片尺寸控制
```css
img {
max-width: 100% !important; height: auto;
page-break-inside: avoid;
}
.chart-image {
display: block; max-width: 100%; height: auto;
margin: 12px auto; page-break-inside: avoid;
}
```
#### 1.5 分页控制
```css
@media print {
@page { margin: 20mm 15mm; }
body { font-size: 10.5pt; }
.section { page-break-inside: avoid; }
h1, h2, h3 { page-break-after: avoid; }
.section-break { page-break-before: always; }
.section-break-auto { page-break-before: auto; }
.no-print { display: none !important; }
}
```
#### 1.6 页脚优化
```css
.footer {
margin-top: 30pt; padding-top: 12pt;
border-top: 1px solid #d0d5dd;
font-size: 8pt; color: #999; text-align: center;
}
```
> **注意**:由于 `buildExportHtml()` 中的 CSS 是模板字符串,需确保引号/反引号正确转义。建议使用 `style +=` 拼接方式或模板字面量。
---
### Phase 2:ReportPanel 图表尺寸控制(ReportPanel.vue)
**文件**`frontend/src/views/performance/ReportPanel.vue``buildReportHtml()` 函数
#### 2.1 所有 `<img>` 增加 width 属性
`chartsHtml` 拼接中,为每个图表 `<img>` 标签增加 `width="700"`
```typescript
// 修改前
if (tpsImg) chartsHtml += `<h2>TPS 趋势</h2><img class="chart-image" src="${tpsImg}" alt="TPS 趋势" />`
// 修改后
if (tpsImg) chartsHtml += `<h2>TPS 趋势</h2><img class="chart-image" width="700" src="${tpsImg}" alt="TPS 趋势" />`
```
**需修改的图表**(9 个):
- TPS 趋势、响应时间趋势、延迟分解趋势、带宽趋势
- 执行机资源趋势、网络趋势
- 目标机资源趋势、Java 进程 CPU 趋势
- 事务瀑布图
---
### Phase 3:ReportPanel 指标卡片改用 table 布局(ReportPanel.vue)
**文件**`frontend/src/views/performance/ReportPanel.vue``buildReportHtml()` 函数
#### 3.1 核心指标卡片(10 个指标 → 3 行 × 4 列 + 1 行 × 2 列)
将 flexbox 的 `metric-grid` 改为 table 布局:
```typescript
// 核心指标卡片
const metrics = [
{ label: '总请求数', value: m.totalRequests, color: '' },
{ label: '成功数', value: m.successCount, color: '' },
{ label: '失败数', value: m.failCount, color: m.failCount > 0 ? '#f56c6c' : '' },
{ label: '错误率', value: (m.errorRate * 100).toFixed(2) + '%', color: m.errorRate > 0.05 ? '#f56c6c' : '' },
{ label: 'TPS', value: m.actualTps.toFixed(1), color: '' },
{ label: '峰值 TPS', value: m.peakTps.toFixed(1), color: '' },
{ label: '平均响应时间', value: m.avgResponseTime.toFixed(1) + 'ms', color: m.avgResponseTime > 1000 ? '#f56c6c' : '' },
{ label: 'P95 响应时间', value: m.p95ResponseTime.toFixed(1) + 'ms', color: m.p95ResponseTime > 1000 ? '#f56c6c' : '' },
{ label: '标准差', value: m.stdDev.toFixed(1) + 'ms', color: '' },
{ label: 'Apdex', value: m.apdex.toFixed(2), color: m.apdex < 0.85 ? '#f56c6c' : '' },
]
// 生成 3 行 × 4 列 + 最后 2 个居中的 table
```
**核心指标显示逻辑**:超过阈值的数值显示红色(`#f56c6c`),正常显示主题色(`#409eff`)。
#### 3.2 执行机资源指标卡片(6 个 → 2 行 × 3 列)
```typescript
${r.resourceSummary ? `
<div class="section">
<h2>执行机资源汇总</h2>
<table class="metric-grid-table">
<tr>
<td class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.avgCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Avg</div></td>
<td class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.maxCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Max</div></td>
<td class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.avgMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Avg</div></td>
</tr>
<tr>
<td class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.maxMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Max</div></td>
<td class="metric-item"><div class="metric-value">${r.resourceSummary.stats.avgSentKbS.toFixed(1)}</div><div class="metric-label">Sent Avg (KB/s)</div></td>
<td class="metric-item"><div class="metric-value">${r.resourceSummary.stats.avgRecvKbS.toFixed(1)}</div><div class="metric-label">Recv Avg (KB/s)</div></td>
</tr>
</table>
</div>` : ''}
```
#### 3.3 目标机资源指标卡片(6 个 → 2 行 × 3 列)
同上结构,显示 CPU Avg/Max、Memory Avg/Max、Load Avg 1m/Max 1m。
#### 3.4 MySQL 监控指标卡片(10 个 → 3 行 × 4 列 + 偏移)
| 行 | 第 1 列 | 第 2 列 | 第 3 列 | 第 4 列 |
|----|---------|---------|---------|---------|
| 1 | 线程连接数 | 活跃线程 | 慢查询 | 缓冲池命中率 |
| 2 | 连接数 Max Used/Max | 运行时间 | 总连接数 | QPS Questions |
| 3 | QPS Queries | 收发流量 | — | — |
---
### Phase 4:表格列宽比例控制(ReportPanel.vue)
**文件**`frontend/src/views/performance/ReportPanel.vue``buildReportHtml()` 函数
#### 4.1 为每个表格添加 `<colgroup>` 或 `<col>` 宽度控制
**执行摘要表**:标签列 20% / 值列 80%
```html
<table>
<colgroup><col style="width:20%"><col style="width:80%"></colgroup>
<tr><td class="label-cell">任务名称</td><td>${r.summary.taskName}</td></tr>
...
</table>
```
**响应时间分位数表**:分位数列 30% / 值列 70%
```html
<table>
<colgroup><col style="width:30%"><col style="width:70%"></colgroup>
...
</table>
```
**状态码分布表**:状态码 20% / 数量 30% / 占比 50%
```html
<table>
<colgroup><col style="width:20%"><col style="width:30%"><col style="width:50%"></colgroup>
...
</table>
```
**Java 进程统计表**(7 列):进程名 25% / 其余 6 列均分 75%(每列 12.5%)
```html
<table>
<colgroup><col style="width:25%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"></colgroup>
...
</table>
```
**事务步骤明细表**(8 列):步骤名 22% / 其余 7 列均分 78%(每列约 11.1%)
```html
<table>
<colgroup><col style="width:22%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"></colgroup>
...
</table>
```
**接口级统计明细表**(9 列):接口名 20% / 其余 8 列均分 80%(每列 10%)
```html
<table>
<colgroup><col style="width:20%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"></colgroup>
...
</table>
```
**快照数据表**(5 列):时间 15% / TPS 20% / 平均响应 20% / P95 20% / 错误率 25%
```html
<table>
<colgroup><col style="width:15%"><col style="width:20%"><col style="width:20%"><col style="width:20%"><col style="width:25%"></colgroup>
...
</table>
```
#### 4.2 长内容自动换行
在 CSS 中确保表格单元格内容自动换行:
```css
td {
word-break: break-word; overflow-wrap: break-word;
}
```
---
### Phase 5:ProjectReport 同步排版优化 + 合并报告导出增强
**文件**`frontend/src/views/performance/ProjectReport.vue``buildReportHtml()` 函数
#### 5.1 同步 CSS 样式
由于 `buildReportHtml()` 的 HTML 内容通过 `buildExportHtml()` 包装,Phase 1 的 CSS 升级会自动应用到合并报告导出。
#### 5.2 表格列宽控制
**执行摘要表**:标签列 20% / 值列 80%
```html
<table>
<colgroup><col style="width:20%"><col style="width:80%"></colgroup>
</table>
```
**各任务明细表**(8 列):任务名 20% / 其余 7 列均分 80%(每列 ~11.4%)
```html
<table>
<colgroup><col style="width:20%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"></colgroup>
</table>
```
#### 5.3 核心指标卡片改用 table 布局
合并报告核心指标(13 个:总请求数、成功/失败数、错误率、整体吞吐量、峰值吞吐量、平均/P95/最小/最大响应时间、标准差、Apdex、发送/接收数据)→ 4 行 × 4 列(最后一行 1 个居中)。
---
## 四、文件变更清单
### 前端
| 文件 | 变更类型 | 关联 FR | 修改内容 |
|------|---------|---------|---------|
| `frontend/src/utils/exportReport.ts` | MODIFY | FR-4, FR-5 | `buildExportHtml()` CSS 全面升级:字体层级、表格样式、指标卡片样式、图片尺寸、分页控制、页脚 |
| `frontend/src/views/performance/ReportPanel.vue` | MODIFY | FR-1, FR-2, FR-3 | ① 9 个图表 `<img>``width="700"`;② 指标卡片改 table 布局(核心、执行机、目标机、MySQL);③ 所有表格加 colgroup 列宽控制 |
| `frontend/src/views/performance/ProjectReport.vue` | MODIFY | FR-6 | ① 指标卡片改 table 布局;② 表格加 colgroup 列宽控制 |
### 文档
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出排版质量优化.md` | ADD | 本需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出排版质量优化_计划执行.md` | ADD | 本计划执行文档 |
---
## 五、验证用例
### TC-1: 图表宽度验证
1. 打开一个含全部 9 个图表的性能测试报告
2. 导出 Word → 用 WPS 打开 → 检查所有图表宽度是否小于页面宽度
3. 导出 PDF → 打印预览 → 检查所有图表是否在页边距内完整显示
4. 检查图片清晰度(不应因缩放而模糊)
### TC-2: 指标卡片布局验证
1. 导出 Word → 检查核心指标区(10 个指标)是否 3×4 网格排列
2. 检查执行机资源指标 2×3 网格排列
3. 检查目标机资源指标 2×3 网格排列
4. 检查 MySQL 指标 3×4 网格排列
5. 在 Word 中调整页面宽度 → 指标卡片不应错位
### TC-3: 表格列宽验证
1. 导出 Word → 检查执行摘要表标签列约 20% 宽
2. 检查快照数据表时间列约 15% 宽,不因内容变长而变化
3. 检查接口统计表接口名列约 20% 宽,长 URL 自动换行
4. 所有表格内容完整,无溢出单元格
### TC-4: 排版视觉效果验证
1. 导出 Word → 整体扫视报告
2. 标题层级是否分明(h1 > h2 > h3 字号递减)
3. 章节间距是否均匀
4. 表头背景色是否正常显示
5. 隔行交替色是否正常
6. 指标卡片数值颜色是否按阈值显示(红色/蓝色)
### TC-5: 分页验证
1. 导出 PDF → 打印预览 → 逐页检查
2. 图表是否完整在一页内(不跨页)
3. 标题是否与后续内容同页(标题不单独在页末)
4. 快照表前是否新起一页
### TC-6: 合并报告验证
1. 打开项目合并报告
2. 导出 Word → 检查核心指标卡片 table 布局
3. 检查各任务明细表列宽比例
4. 导出 PDF → 检查分页合理
---
## 六、风险与应对
| 风险 | 影响 | 概率 | 应对 |
|------|------|------|------|
| `display: inline-block` 在 Word 中仍不兼容 | 指标卡片错位 | 中 | 备选方案:完全用 `<table>` 实现指标网格(每行写死 `<tr>`) |
| `colgroup` 宽度在 Word 中不生效 | 列宽不受控 | 中 | 备选:改用 `<th width="XX%">` 或内联 `style="width:XX%"``td` 上 |
| Word 中 `tr:nth-child(even)` 不生效 | 隔行交替色丢失 | 高 | 备选:在 JS 拼接时手动为偶数行 `td``style="background:#fafbfc"` |
| 内联样式过多导致 HTML 体积暴增 | 导出文件变大 | 低 | 仅对关键样式(表格边框、背景色、宽度)用内联,其余用类选择器 |
| `page-break-inside: avoid` 在 Word 中失效 | 表格仍跨页 | 中 | 对长表(快照表)改用 `page-break-before: always` 强制分页 |
---
## 七、实施顺序建议
```
Phase 1 (CSS 样式) → Phase 2 (图表宽度) → Phase 3 (指标卡片) → Phase 4 (列宽控制) → Phase 5 (合并报告)
```
**原因**:CSS 样式是基础,先升级后所有导出 HTML 自动受益;图表宽度控制最简单先做;指标卡片改 table 布局改动量最大放中间;列宽控制独立可并行;合并报告改动最小最后做。
---
*本文件由 Claude Code 生成,用于指导报告导出排版质量优化的实施。*
\ No newline at end of file
......@@ -3,7 +3,51 @@
> **生成时间**: 2026-08-25
> **当前分支**: `platform-auto-test`
> **最近提交**: `f4892d6c` docs(handoff): 精简 HANDOFF_UI自动化文档 + 更新会话37状态
> **状态**: 🟢 **会话39:薄弱模块 31 个用例全量修复验证 R7 通过率 100%。Phase 4 直达导航接入 `_do_navigate` 完整路径(含 reload 兜底),彻底解决 monitor/meetingV3 二次导航 NO-BODY 顽疾。**
> **状态**: 🟢 **会话40:新增用例数据全量同步三台测试管理平台(5.44/5.202/5.60)——本地 SQLite 315 条用例 UPSERT 至三端 MySQL(5.44/5.202 各 371→393 补 22 条、5.60 393→394 补 1 条),含会话37/38/39 新增用例 + 14 条断言对齐更新;沉淀 `sync_cases_to_servers.py`。**
---
## 📊 当前状态(会话 40,2026-08-25)
**新增用例数据全量同步三台测试管理平台(192.168.5.44 / 192.168.5.202 / 192.168.5.60)**:应需求将本地新增/更新的用例数据补充到三台服务器。三台应用均为 Docker MySQL 架构(`plat-auto-test-app` 容器内连 `mysql` 容器,库 `plat_auto_test`,表 `test_cases`),直接 SQL UPSERT 写入即可生效,无需重启容器。
### ✅ 三端用例库同步(会话 40,2026-08-25)
**差异对比**
| 数据源 | 用例数 |
|--------|--------|
| 本地 SQLite(`backend/data/test_platform.db`) | 315 条 |
| 5.44 / 5.202 MySQL(同步前) | 371 条(缺 22 条本地新增) |
| 5.60 MySQL(同步前) | 393 条(缺 1 条本地新增) |
**同步结果**(全量 UPSERT,单向,不删除远端独有用例):
| 服务器 | 同步前 | 同步后 | 新增 | 更新 |
|--------|--------|--------|------|------|
| **5.44** | 371 | **393** | 22 | 293 |
| **5.202** | 371 | **393** | 22 | 293 |
| **5.60** | 393 | **394** | 1 | 314 |
**覆盖内容**
- **会话 38 新增**`资产信息-新增资产闭环`(case_bc72e3de...,27 步闭环用例)→ 三台均补 ✅
- **会话 37 新增**:待办事项 3 个 + 会议室列表 7 个直达 URL 化 + 资产信息 3 个(5.44/5.202 各补 22 条)
- **会话 39 更新**:14 个用例断言对齐(通讯录/会务工单/消息通知/信息窗管理等,updated_at=2026-08-24 17:30)→ 三台均更新 ✅
**验证**
- 三台用例数:5.44=393、5.202=393、5.60=394 ✅
- 模块表三端无缺失(本地 28 个模块在三端全部存在;远端另有 sec_* 安全等本地没有的模块,未受影响)✅
- 抽查 9 个关键用例(新增闭环 + 会话37 新用例 + 会话39 断言对齐):三台 **9/9 全部命中**,updated_at 与本地一致 ✅
**改动清单**
| 文件 | 说明 |
|------|------|
| `backend/scripts/sync_cases_to_servers.py` | 新增同步脚本:读本地 SQLite 全量用例 UPSERT 到 5.44/5.202/5.60 三台 MySQL(docker exec 管道传 JSON,无重启) |
**遗留说明**:本次仅同步用例数据(`test_cases` 表)。会话 39 的 `playwright_executor.py` 两处代码修复(`_do_navigate` 接入 + reload 兜底)**尚未部署**三台服务器;14 个用例断言对齐已随用例同步到位(但执行器无 reload 兜底时,薄弱模块在远端仍可能复现 NO-BODY 问题,需尽快部署代码)。
---
......@@ -229,6 +273,7 @@ button:has-text('确定')
| 会话 | 日期 | 主题 | 结果 |
|------|------|------|------|
| 40 | 08-25 | **新增用例数据同步三台测试管理平台**:本地 315 条 UPSERT 至 5.44/5.202/5.60 MySQL(各补 22/22/1 条,含会话37/38/39 新增 + 14 条断言对齐更新);沉淀 `sync_cases_to_servers.py`(docker exec 管道传 JSON,无重启) | ✅ 5.44=393、5.202=393、5.60=394,抽查 9 用例三台全命中 |
| 39 | 08-25 | **薄弱模块 31 用例全量修复 R7 100%**`_navigate_to_target_page` 改走 `_do_navigate`(含 reload 兜底);`_ensure_micro_app_loaded` 阶段4 reload 修复;5 个探测脚本沉淀;14 个用例断言对齐 | ✅ 本地 31/31,799.7s |
| 38 | 08-24 | **资产信息-新增资产闭环用例**:Playwright 4 轮探测添加弹窗(表单/radio/cascader/select/MessageBox);生成 27 步闭环用例(新增→验证→删除);首次执行 27/27 100% 通过 | ✅ 本地 1/1,254.9s |
| 37 | 08-21 | **会议室列表 + 资产信息直达 URL 化改造 + 5.60 部署**:7+3 用例 navigate 直达;page_url_mapping 补全 asset_info;`element_count_min` micro-app 8s 轮询兜底 | ✅ 本地 10/10、已部署 5.60 |
......@@ -328,7 +373,7 @@ cd frontend && npm run build
### P2(低优先级)
- [ ] **排查搜索确定按钮 88s 卡点**(会话 38 新增资产闭环 Step 16/25 各 88s,功能通过但拉长总时长 254.9s)
- [ ] **薄弱模块 R7 修复部署 5.60**(会话 39 产出:`playwright_executor.py` 两处修复 + 14 个用例断言对齐,仅本地验证 31/31,尚未同步生产
- [ ] **薄弱模块 R7 修复部署三台服务器(5.44/5.202/5.60)**(会话 39 产出 `playwright_executor.py` 两处修复 + 14 个断言对齐用例。⚠️ 会话 40 已同步用例数据到位,但**执行器代码修复尚未部署**三台——远端薄弱模块仍可能复现 NO-BODY,需尽快部署;可复用 `deploy_to_544_5202.py`/`sync_to_560_full.py`
- [ ] **monitor 内层路由被忽略**(微应用固定渲染设备列表,忽略 URL 内层 Maintenancelist/Filemanage 路由;相关用例已按实际渲染断言,如需真正内页需进一步探测侧边栏交互方案)
- [ ] 完善二级菜单映射表(`menu_mapping.py`
- [ ] 智能定位 API 与用例执行路径统一(当前两者逻辑分离)
......@@ -344,4 +389,4 @@ cd frontend && npm run build
---
*本文档由 Claude Code 于 2026-08-25 更新(会话 39:薄弱模块 31 个用例全量修复验证 R7 通过率 100%——Phase 4 直达导航 `_navigate_to_target_page` 接入 `_do_navigate` 完整路径 + `_ensure_micro_app_loaded` 阶段4 reload 兜底,彻底解决 monitor/meetingV3 二次导航 NO-BODY;14 个用例断言对齐;5 个探测脚本沉淀 `backend/scripts/`)。*
\ No newline at end of file
*本文档由 Claude Code 于 2026-08-25 更新(会话 40:新增用例数据全量同步 5.44/5.202/5.60 三台测试管理平台——本地 SQLite 315 条用例通过 `sync_cases_to_servers.py` UPSERT 至三端 MySQL,承诺不删远端独有用例;会话39 14 条断言对齐随用例同步到位,但执行器代码修复尚未部署三台)。*
\ No newline at end of file
......@@ -4,6 +4,12 @@
* 提供 Word (.doc) 和 PDF 导出功能,通过前端 HTML 渲染 + 浏览器能力实现,
* 不依赖后端 reportlab 等服务端库。
*
* 排版规范(Word 兼容优先):
* - 指标卡片用 <table> 网格布局(flexbox 在 Word 中不支持)
* - 表格隔行交替色在 JS 拼接时手动写入 style(nth-child 在 Word 中不生效)
* - 图表 <img> 必须带 width 属性(Word 按图片实际像素渲染,CSS max-width 无效)
* - 表格列宽用 <colgroup> 控制(Word 支持良好)
*
* @author czj
* @date 2026-08-25
*/
......@@ -22,11 +28,11 @@ import type { ECharts } from 'echarts'
*/
export function exportWord(title: string, htmlContent: string, filename: string): void {
const fullHtml = buildExportHtml(title, htmlContent)
const blob = new Blob([fullHtml], { type: 'application/msword;charset=utf-8' })
const blob = new Blob(['' + fullHtml], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.doc`
a.download = `${sanitizeFilename(filename)}.doc`
a.click()
URL.revokeObjectURL(url)
}
......@@ -39,8 +45,9 @@ export function exportWord(title: string, htmlContent: string, filename: string)
*
* @param title 文档标题
* @param htmlContent 完整 HTML 内容(含样式、表格、图表 base64 图片等)
* @param filename 打印窗口标题(浏览器「另存为 PDF」默认文件名来源,缺省用 title)
*/
export function exportPdf(title: string, htmlContent: string): void {
export function exportPdf(title: string, htmlContent: string, filename?: string): void {
const fullHtml = buildExportHtml(title, htmlContent)
const win = window.open('', '_blank')
if (!win) {
......@@ -48,7 +55,7 @@ export function exportPdf(title: string, htmlContent: string): void {
return
}
win.document.write(fullHtml)
win.document.title = title
win.document.title = filename || title
win.document.close()
// 等待所有图片加载完成后再触发打印(超时兜底 5s)
waitForImages(win, 5000).then(() => {
......@@ -114,7 +121,7 @@ export function exportJson(data: any, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.json`
a.download = `${sanitizeFilename(filename)}.json`
a.click()
URL.revokeObjectURL(url)
}
......@@ -123,7 +130,6 @@ export function exportJson(data: any, filename: string): void {
* 从 ECharts 实例获取 base64 图片
*
* @param chart ECharts 实例
* @param width 图片宽度(默认 800)
* @returns base64 图片 data URL,或空字符串(图表未初始化时)
*/
export function getChartImage(chart: ECharts | null): string {
......@@ -139,6 +145,42 @@ export function getChartImage(chart: ECharts | null): string {
}
}
/**
* 构建指标卡片网格 HTML(Word 兼容 table 布局)
*
* 用 <table> 替代 flexbox 网格,每行 perRow 个卡片,Word/WPS/PDF 三端渲染一致。
* 单元格无表格线(metric-grid-table 无边框),卡片自身有浅色边框和背景。
*
* @param items 指标数组 [{label, value, color?}]
* @param perRow 每行卡片数(默认 4)
* @returns table HTML 字符串
*
* @example
* buildMetricGrid([
* { label: '总请求数', value: '4033' },
* { label: '错误率', value: '0.00%', color: '#67c23a' },
* ], 4)
*/
export function buildMetricGrid(
items: { label: string; value: string; color?: string }[],
perRow = 4,
): string {
const rows: string[] = []
for (let i = 0; i < items.length; i += perRow) {
const rowItems = items.slice(i, i + perRow)
const tds = rowItems
.map(
(it) =>
`<td class="metric-item"><span class="metric-value"${it.color ? ` style="color:${it.color}"` : ''}>${it.value}</span><span class="metric-label">${it.label}</span></td>`,
)
.join('')
// 不足一行的用空单元格补齐,保持列宽一致
const pad = Array.from({ length: perRow - rowItems.length }, () => '<td class="metric-item metric-item-empty">&nbsp;</td>').join('')
rows.push(`<tr>${tds}${pad}</tr>`)
}
return `<table class="metric-grid-table" cellspacing="8" cellpadding="0">${rows.join('')}</table>`
}
/**
* 构建完整的导出 HTML 页面
*
......@@ -148,157 +190,234 @@ export function getChartImage(chart: ECharts | null): string {
*/
function buildExportHtml(title: string, contentBody: string): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom></w:WordDocument></xml><![endif]-->
<style>
/* 打印样式 */
/* ============ 打印/PDF 分页控制 ============ */
@media print {
@page { margin: 20mm 15mm; }
body { font-size: 11pt; }
@page { margin: 18mm 14mm; }
body { font-size: 10.5pt; }
.no-print { display: none !important; }
img { max-width: 100% !important; page-break-inside: avoid; }
table { page-break-inside: avoid; }
table { page-break-inside: auto; }
table.metric-grid-table tr { page-break-inside: avoid; }
.data-table { page-break-inside: auto; }
.data-table tr { page-break-inside: avoid; }
h1, h2, h3, h4 { page-break-after: avoid; }
.section { page-break-inside: avoid; }
.section { page-break-inside: auto; }
.section-break { page-break-before: always; }
.keep-together { page-break-inside: avoid; }
}
/* Word 兼容 */
/* ============ Word 兼容 ============ */
table {
mso-border-alt: thin solid #dcdfe6;
mso-border-between: thin solid #dcdfe6;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
mso-border-alt: thin solid #d0d5dd;
mso-border-between: thin solid #d0d5dd;
border-collapse: collapse;
}
td, th {
mso-border-alt: thin solid #dcdfe6;
padding: 4px 8px;
mso-border-alt: thin solid #d0d5dd;
mso-padding-alt: 4px 8px;
}
/* 屏幕样式 */
/* ============ 基础排版 ============ */
body {
font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
color: #333;
padding: 20px;
max-width: 1100px;
font-size: 10.5pt;
color: #333333;
padding: 24px 32px;
max-width: 1000px;
margin: 0 auto;
line-height: 1.6;
line-height: 1.7;
}
/* ============ 标题层级 ============ */
h1 {
font-size: 22pt;
color: #303133;
border-bottom: 2px solid #409eff;
padding-bottom: 8px;
margin-bottom: 20px;
font-size: 21pt;
font-weight: 700;
color: #1a1a1a;
border-bottom: 3px solid #409eff;
padding-bottom: 10px;
margin: 0 0 20pt 0;
letter-spacing: 1pt;
}
h2 {
font-size: 16pt;
color: #303133;
margin-top: 24px;
margin-bottom: 12px;
font-size: 14pt;
font-weight: 600;
color: #2c3e50;
border-bottom: 1px solid #e4e7ed;
padding-bottom: 6px;
margin: 20pt 0 10pt 0;
page-break-after: avoid;
}
h3 {
font-size: 13pt;
color: #606266;
margin-top: 16px;
margin-bottom: 8px;
font-size: 12pt;
font-weight: 600;
color: #333333;
margin: 14pt 0 8pt 0;
page-break-after: avoid;
}
table {
p {
margin: 6pt 0;
}
/* ============ 数据表格 ============ */
table.data-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
margin: 10pt 0;
font-size: 9pt;
page-break-inside: auto;
}
table, th, td {
border: 1px solid #dcdfe6;
}
table.data-table tr { page-break-inside: avoid; }
th {
background: #f5f7fa;
table.data-table th {
background: #f0f2f5;
font-weight: 600;
color: #2c3e50;
padding: 8px 10px;
text-align: left;
border: 0.5pt solid #d0d5dd;
white-space: nowrap;
}
td {
table.data-table td {
padding: 6px 10px;
border: 0.5pt solid #d0d5dd;
word-break: break-word;
overflow-wrap: break-word;
vertical-align: top;
}
.metric-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 12px 0;
}
.metric-item {
flex: 1;
min-width: 140px;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px 16px;
text-align: center;
background: #fafafa;
/* 隔行交替色(浏览器/PDF 生效;Word 中靠 JS 手动内联 style 兜底) */
table.data-table tr:nth-child(even) td {
background: #fafbfc;
}
.metric-value {
font-size: 18pt;
font-weight: 700;
color: #409eff;
table.data-table tr.alt td {
background: #fafbfc;
}
.metric-label {
font-size: 9pt;
color: #909399;
margin-top: 4px;
/* 数值列右对齐 */
table.data-table th.num, table.data-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.chart-image {
/* ============ 摘要表(label-value 两列) ============ */
table.summary-table {
width: 100%;
max-width: 800px;
margin: 12px 0;
border-collapse: collapse;
margin: 10pt 0;
font-size: 10pt;
}
.section {
margin-bottom: 20px;
table.summary-table td {
padding: 7px 12px;
border: 0.5pt solid #d0d5dd;
vertical-align: top;
}
.summary-block {
margin: 12px 0;
table.summary-table .label-cell {
font-weight: 600;
color: #2c3e50;
background: #f7f8fa;
white-space: nowrap;
width: 20%;
}
.summary-block table {
/* ============ 指标卡片网格(Word 兼容 table 布局) ============ */
table.metric-grid-table {
width: 100%;
border-collapse: separate;
border-spacing: 8px 8px;
margin: 10pt 0;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border: none;
}
.summary-block td {
padding: 6px 12px;
table.metric-grid-table td.metric-item {
width: 25%;
text-align: center;
vertical-align: middle;
background: #f8f9fa;
border: 1px solid #e8ebf0;
mso-border-alt: thin solid #e8ebf0;
padding: 12pt 6pt;
page-break-inside: avoid;
}
.label-cell {
font-weight: 600;
color: #606266;
white-space: nowrap;
width: 140px;
table.metric-grid-table td.metric-item-empty {
background: transparent;
border: none;
mso-border-alt: none;
}
.footer {
margin-top: 30px;
padding-top: 12px;
border-top: 1px solid #dcdfe6;
font-size: 9pt;
color: #909399;
.metric-value {
display: block;
font-size: 15pt;
font-weight: 700;
color: #409eff;
line-height: 1.4;
mso-line-height-rule: exactly;
}
.metric-label {
display: block;
font-size: 8pt;
color: #666666;
margin-top: 4px;
line-height: 1.3;
mso-line-height-rule: exactly;
}
/* ============ 图表图片 ============ */
.chart-block {
margin: 14pt 0;
page-break-inside: avoid;
text-align: center;
}
img {
.chart-block img.chart-image {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border: 1px solid #ebeef5;
page-break-inside: avoid;
}
img { max-width: 100%; height: auto; }
/* ============ 章节与辅助 ============ */
.section { margin: 0 0 14pt 0; }
.section-break { page-break-before: always; }
.keep-together { page-break-inside: avoid; }
.sub-desc {
color: #909399;
font-size: 9pt;
margin: 2pt 0 8pt 0;
}
.footer {
margin-top: 30pt;
padding-top: 12pt;
border-top: 1px solid #d0d5dd;
font-size: 8pt;
color: #999999;
text-align: center;
}
</style>
</head>
......@@ -311,6 +430,13 @@ function buildExportHtml(title: string, contentBody: string): string {
</html>`
}
/**
* 清理下载文件名中的 Windows/macOS 不允许字符,保留任务名称可读性。
*/
function sanitizeFilename(filename: string): string {
return (filename || '性能测试报告').replace(/[\\/:*?"<>|]/g, '_').trim() || '性能测试报告'
}
/**
* HTML 转义
*/
......@@ -320,4 +446,4 @@ function escapeHtml(str: string): string {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
\ No newline at end of file
}
......@@ -269,7 +269,7 @@ import { ElMessage } from 'element-plus'
import { ArrowLeft, Refresh, Download, ArrowDown } from '@element-plus/icons-vue'
import { getProjectReport, getBatchReport, getProject, getProjectReports } from '@/api/performance'
import type { ProjectReportResponse, PerformanceProject, ProjectReportListResponse } from '@/types/performance'
import { exportWord, exportPdf, exportJson } from '@/utils/exportReport'
import { exportWord, exportPdf, exportJson, buildMetricGrid } from '@/utils/exportReport'
const route = useRoute()
const router = useRouter()
......@@ -381,7 +381,8 @@ function onExportCommand(command: string) {
/** 导出报告 JSON */
function exportReportJson() {
if (!report.value) return
exportJson(report.value, `report_${report.value.id || 'merged'}`)
const name = project.value?.name || report.value.id || '合并报告'
exportJson(report.value, name)
}
/** 导出 Word */
......@@ -389,7 +390,8 @@ function exportWordToDoc() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportWord(`合并报告 - ${r.id || 'merged'}`, html, `report_${r.id || 'merged'}`)
const name = project.value?.name || r.id || '合并报告'
exportWord(`合并报告 - ${name}`, html, name)
}
/** 导出 PDF */
......@@ -397,17 +399,35 @@ function exportPdfFromReport() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportPdf(`合并报告 - ${r.id || 'merged'}`, html)
const name = project.value?.name || r.id || '合并报告'
exportPdf(`合并报告 - ${name}`, html, name)
}
/** 构建合并报告 HTML 内容 */
function buildReportHtml(r: ProjectReportResponse): string {
const s = r.summary
const coreMetrics = [
{ label: '总请求数', value: String(s.totalRequests), color: s.totalRequests > 0 ? '#409eff' : undefined },
{ label: '总成功数', value: String(s.successCount), color: s.successCount > 0 ? '#67c23a' : undefined },
{ label: '总失败数', value: String(s.failCount), color: s.failCount > 0 ? '#f56c6c' : undefined },
{ label: '整体错误率', value: (s.errorRate * 100).toFixed(2) + '%', color: s.errorRate > 0.05 ? '#f56c6c' : (s.errorRate > 0 ? '#e6a23c' : '#67c23a') },
{ label: '整体吞吐量', value: s.actualTps.toFixed(1), color: s.actualTps > 0 ? '#409eff' : undefined },
{ label: '峰值吞吐量', value: s.peakTps.toFixed(1) },
{ label: '平均响应时间', value: s.avgResponseTime.toFixed(1) + 'ms', color: s.avgResponseTime > 1000 ? '#f56c6c' : (s.avgResponseTime > 300 ? '#e6a23c' : '#409eff') },
{ label: 'P95 响应时间', value: s.p95ResponseTime.toFixed(1) + 'ms', color: s.p95ResponseTime > 1000 ? '#f56c6c' : (s.p95ResponseTime > 500 ? '#e6a23c' : '#409eff') },
{ label: '最小响应时间', value: s.minResponseTime.toFixed(1) + 'ms' },
{ label: '最大响应时间', value: s.maxResponseTime.toFixed(1) + 'ms', color: s.maxResponseTime > 5000 ? '#f56c6c' : undefined },
{ label: '标准差', value: s.stdDev.toFixed(1) + 'ms' },
{ label: 'Apdex', value: s.apdex.toFixed(2), color: s.apdex < 0.85 ? '#f56c6c' : (s.apdex < 0.94 ? '#e6a23c' : '#67c23a') },
{ label: '总发送数据', value: formatBytes(s.totalSentBytes) },
{ label: '总接收数据', value: formatBytes(s.totalReceivedBytes) },
]
return `
<h1>性能测试合并报告</h1>
<div class="section">
<div class="section keep-together">
<h2>执行摘要</h2>
<table>
<table class="summary-table">
<colgroup><col style="width:20%"><col style="width:80%"></colgroup>
<tr><td class="label-cell">报告 ID</td><td>${r.id}</td></tr>
<tr><td class="label-cell">批次 ID</td><td>${r.batchId || '-'}</td></tr>
<tr><td class="label-cell">生成时间</td><td>${formatTime(r.createdAt)}</td></tr>
......@@ -417,62 +437,50 @@ function buildReportHtml(r: ProjectReportResponse): string {
</table>
</div>
<div class="section">
<div class="section keep-together">
<h2>核心指标</h2>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value">${s.totalRequests}</div><div class="metric-label">总请求数</div></div>
<div class="metric-item"><div class="metric-value">${s.successCount}</div><div class="metric-label">总成功数</div></div>
<div class="metric-item"><div class="metric-value">${s.failCount}</div><div class="metric-label">总失败数</div></div>
<div class="metric-item"><div class="metric-value">${(s.errorRate * 100).toFixed(2)}%</div><div class="metric-label">整体错误率</div></div>
<div class="metric-item"><div class="metric-value">${s.actualTps.toFixed(1)}</div><div class="metric-label">整体吞吐量</div></div>
<div class="metric-item"><div class="metric-value">${s.peakTps.toFixed(1)}</div><div class="metric-label">峰值吞吐量</div></div>
<div class="metric-item"><div class="metric-value">${s.avgResponseTime.toFixed(1)}ms</div><div class="metric-label">平均响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.p95ResponseTime.toFixed(1)}ms</div><div class="metric-label">P95 响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.minResponseTime.toFixed(1)}ms</div><div class="metric-label">最小响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.maxResponseTime.toFixed(1)}ms</div><div class="metric-label">最大响应时间</div></div>
<div class="metric-item"><div class="metric-value">${s.stdDev.toFixed(1)}ms</div><div class="metric-label">标准差</div></div>
<div class="metric-item"><div class="metric-value">${s.apdex.toFixed(2)}</div><div class="metric-label">Apdex</div></div>
<div class="metric-item"><div class="metric-value">${formatBytes(s.totalSentBytes)}</div><div class="metric-label">总发送数据</div></div>
<div class="metric-item"><div class="metric-value">${formatBytes(s.totalReceivedBytes)}</div><div class="metric-label">总接收数据</div></div>
</div>
${buildMetricGrid(coreMetrics, 4)}
</div>
<div class="section">
<div class="section keep-together">
<h2>响应时间分位数</h2>
<table>
<tr><th>分位数</th><th>值 (ms)</th></tr>
<tr><td>P50</td><td>${s.p50ResponseTime.toFixed(1)}</td></tr>
<tr><td>P90</td><td>${s.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td>${s.p95ResponseTime.toFixed(1)}</td></tr>
<tr><td>P99</td><td>${s.p99ResponseTime.toFixed(1)}</td></tr>
<table class="data-table">
<colgroup><col style="width:30%"><col style="width:70%"></colgroup>
<tr><th>分位数</th><th class="num">值 (ms)</th></tr>
<tr><td>P50</td><td class="num">${s.p50ResponseTime.toFixed(1)}</td></tr>
<tr class="alt"><td>P90</td><td class="num">${s.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td class="num">${s.p95ResponseTime.toFixed(1)}</td></tr>
<tr class="alt"><td>P99</td><td class="num">${s.p99ResponseTime.toFixed(1)}</td></tr>
</table>
</div>
<div class="section">
<div class="section keep-together">
<h2>状态码分布</h2>
<table>
<tr><th>状态码</th><th>数量</th><th>占比</th></tr>
<tr><td>2xx</td><td>${s.status2xx}</td><td>${((s.status2xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>3xx</td><td>${s.status3xx}</td><td>${((s.status3xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td>${s.status4xx}</td><td>${((s.status4xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>5xx</td><td>${s.status5xx}</td><td>${((s.status5xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<table class="data-table">
<colgroup><col style="width:20%"><col style="width:30%"><col style="width:50%"></colgroup>
<tr><th>状态码</th><th class="num">数量</th><th class="num">占比</th></tr>
<tr><td>2xx</td><td class="num">${s.status2xx}</td><td class="num">${((s.status2xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr class="alt"><td>3xx</td><td class="num">${s.status3xx}</td><td class="num">${((s.status3xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td class="num">${s.status4xx}</td><td class="num">${((s.status4xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr class="alt"><td>5xx</td><td class="num">${s.status5xx}</td><td class="num">${((s.status5xx / (s.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
</table>
</div>
<div class="section">
<h2>各任务明细(${r.taskSummaries.length} 个任务)</h2>
<table>
<tr><th>任务名称</th><th>状态</th><th>请求数</th><th>成功</th><th>失败</th><th>错误率</th><th>平均响应(ms)</th><th>TPS</th></tr>
${r.taskSummaries.map(t => `
<tr>
<table class="data-table">
<colgroup><col style="width:20%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"><col style="width:11.4%"></colgroup>
<tr><th>任务名称</th><th class="num">状态</th><th class="num">请求数</th><th class="num">成功</th><th class="num">失败</th><th class="num">错误率</th><th class="num">平均响应</th><th class="num">TPS</th></tr>
${r.taskSummaries.map((t, idx) => `
<tr${idx % 2 === 1 ? ' class="alt"' : ''}>
<td>${t.taskName}</td>
<td>${statusLabel(t.status)}</td>
<td>${t.totalRequests}</td>
<td>${t.successCount}</td>
<td>${t.failCount}</td>
<td>${(t.errorRate * 100).toFixed(1)}%</td>
<td>${t.avgResponseTime.toFixed(1)}</td>
<td>${t.actualTps.toFixed(1)}</td>
<td class="num">${statusLabel(t.status)}</td>
<td class="num">${t.totalRequests}</td>
<td class="num">${t.successCount}</td>
<td class="num" style="color:${t.failCount > 0 ? '#f56c6c' : '#909399'}">${t.failCount}</td>
<td class="num" style="color:${t.errorRate > 0.05 ? '#f56c6c' : (t.errorRate > 0 ? '#e6a23c' : '#67c23a')}">${(t.errorRate * 100).toFixed(1)}%</td>
<td class="num">${t.avgResponseTime.toFixed(1)}</td>
<td class="num">${t.actualTps.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>
......
......@@ -881,7 +881,7 @@ import type { EChartsOption } from 'echarts'
import type { PerformanceTaskListItem, PerformanceExecutionListItem } from '@/types/performance'
import { marked } from 'marked'
import RequestDetailPanel from './RequestDetailPanel.vue'
import { exportWord, exportPdf, exportJson, getChartImage } from '@/utils/exportReport'
import { exportWord, exportPdf, exportJson, getChartImage, buildMetricGrid } from '@/utils/exportReport'
const route = useRoute()
const router = useRouter()
......@@ -1162,7 +1162,7 @@ function onExportCommand(command: string) {
/** 导出报告 JSON */
function exportReportJson() {
if (!report.value) return
exportJson(report.value, `perf-report-${report.value.summary.taskId}`)
exportJson(report.value, report.value.summary.taskName)
}
/** 导出 Word */
......@@ -1170,7 +1170,7 @@ function exportWordToDoc() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportWord(`性能测试报告 - ${r.summary.taskName}`, html, `perf-report-${r.summary.taskId}`)
exportWord(`性能测试报告 - ${r.summary.taskName}`, html, r.summary.taskName)
}
/** 导出 PDF */
......@@ -1178,7 +1178,7 @@ function exportPdfFromReport() {
if (!report.value) return
const r = report.value
const html = buildReportHtml(r)
exportPdf(`性能测试报告 - ${r.summary.taskName}`, html)
exportPdf(`性能测试报告 - ${r.summary.taskName}`, html, r.summary.taskName)
}
/** 构建报告 HTML 内容(含图表 base64 图片) */
......@@ -1200,166 +1200,180 @@ function buildReportHtml(r: PerformanceReportResponse): string {
const javaProcessImg = getChartImage(javaProcessChart)
const waterfallImg = getChartImage(waterfallChart)
if (tpsImg) chartsHtml += `<h2>TPS 趋势</h2><img class="chart-image" src="${tpsImg}" alt="TPS 趋势" />`
if (rtImg) chartsHtml += `<h2>响应时间趋势</h2><img class="chart-image" src="${rtImg}" alt="响应时间趋势" />`
if (latencyImg) chartsHtml += `<h2>延迟分解趋势</h2><img class="chart-image" src="${latencyImg}" alt="延迟分解趋势" />`
if (bandwidthImg) chartsHtml += `<h2>带宽趋势</h2><img class="chart-image" src="${bandwidthImg}" alt="带宽趋势" />`
if (tpsImg) chartsHtml += `<div class="chart-block"><h2>TPS 趋势</h2><img class="chart-image" width="700" src="${tpsImg}" alt="TPS 趋势" /></div>`
if (rtImg) chartsHtml += `<div class="chart-block"><h2>响应时间趋势</h2><img class="chart-image" width="700" src="${rtImg}" alt="响应时间趋势" /></div>`
if (latencyImg) chartsHtml += `<div class="chart-block"><h2>延迟分解趋势</h2><img class="chart-image" width="700" src="${latencyImg}" alt="延迟分解趋势" /></div>`
if (bandwidthImg) chartsHtml += `<div class="chart-block"><h2>带宽趋势</h2><img class="chart-image" width="700" src="${bandwidthImg}" alt="带宽趋势" /></div>`
// 执行机资源:图表 + 网络趋势(与页面 v-if 条件一致)
if (r.resourceSummary) {
if (resourceImg) chartsHtml += `<h2>执行机资源趋势</h2><img class="chart-image" src="${resourceImg}" alt="执行机资源趋势" />`
if (networkImg) chartsHtml += `<h2>网络趋势(发送 / 接收 KB/s)</h2><img class="chart-image" src="${networkImg}" alt="网络趋势" />`
if (resourceImg) chartsHtml += `<div class="chart-block"><h2>执行机资源趋势</h2><img class="chart-image" width="700" src="${resourceImg}" alt="执行机资源趋势" /></div>`
if (networkImg) chartsHtml += `<div class="chart-block"><h2>网络趋势(发送 / 接收 KB/s)</h2><img class="chart-image" width="700" src="${networkImg}" alt="网络趋势" /></div>`
}
// 目标机资源:趋势图 + Java 进程 CPU 趋势(有数据时才导出)
if (targetRs) {
if (targetResourceImg) chartsHtml += `<h2>目标机资源趋势</h2><img class="chart-image" src="${targetResourceImg}" alt="目标机资源趋势" />`
if (targetResourceImg) chartsHtml += `<div class="chart-block"><h2>目标机资源趋势</h2><img class="chart-image" width="700" src="${targetResourceImg}" alt="目标机资源趋势" /></div>`
if (targetRs.javaProcessSeries?.length && javaProcessImg) {
chartsHtml += `<h2>Java 服务 CPU 趋势</h2><img class="chart-image" src="${javaProcessImg}" alt="Java 服务 CPU 趋势" />`
chartsHtml += `<div class="chart-block"><h2>Java 服务 CPU 趋势</h2><img class="chart-image" width="700" src="${javaProcessImg}" alt="Java 服务 CPU 趋势" /></div>`
}
}
// 事务瀑布图(有步骤数据时才导出)
if (transaction?.stepSummaries?.length && waterfallImg) {
chartsHtml += `<h2>事务步骤瀑布图</h2><img class="chart-image" src="${waterfallImg}" alt="事务步骤瀑布图" />`
chartsHtml += `<div class="chart-block"><h2>事务步骤瀑布图</h2><img class="chart-image" width="700" src="${waterfallImg}" alt="事务步骤瀑布图" /></div>`
}
// ==================== 文字模块(与页面 v-if 条件一致) ====================
// A. 执行机资源指标卡片
const resourceHtml = r.resourceSummary ? `
<div class="section">
<h2>执行机资源汇总</h2>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.avgCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.maxCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Max</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.avgMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.resourceSummary.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.resourceSummary.stats.maxMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Max</div></div>
<div class="metric-item"><div class="metric-value">${r.resourceSummary.stats.avgSentKbS.toFixed(1)}</div><div class="metric-label">Sent Avg (KB/s)</div></div>
<div class="metric-item"><div class="metric-value">${r.resourceSummary.stats.avgRecvKbS.toFixed(1)}</div><div class="metric-label">Recv Avg (KB/s)</div></div>
</div>
</div>` : ''
// B. 目标机资源指标卡片
const targetResourceHtml = targetRs ? `
<div class="section">
<h2>目标机资源汇总</h2>
<p style="color:#909399;font-size:10pt;">SSH ${targetRs.host}</p>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value" style="color:${targetRs.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${targetRs.stats.avgCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${targetRs.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${targetRs.stats.maxCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Max</div></div>
<div class="metric-item"><div class="metric-value" style="color:${targetRs.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff'}">${targetRs.stats.avgMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${targetRs.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff'}">${targetRs.stats.maxMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Max</div></div>
<div class="metric-item"><div class="metric-value">${targetRs.stats.avgLoadAvg1.toFixed(2)}</div><div class="metric-label">Load Avg 1m</div></div>
<div class="metric-item"><div class="metric-value">${targetRs.stats.maxLoadAvg1.toFixed(2)}</div><div class="metric-label">Load Max 1m</div></div>
</div>
</div>` : ''
// ==================== 指标卡片 ====================
// 核心指标卡片(10 个 → 3 行 × 4 列,最后一行 2 个居中)
const coreMetrics = [
{ label: '总请求数', value: String(m.totalRequests), color: m.totalRequests > 0 ? '#409eff' : undefined },
{ label: '成功数', value: String(m.successCount), color: m.successCount > 0 ? '#67c23a' : undefined },
{ label: '失败数', value: String(m.failCount), color: m.failCount > 0 ? '#f56c6c' : undefined },
{ label: '错误率', value: (m.errorRate * 100).toFixed(2) + '%', color: m.errorRate > 0.05 ? '#f56c6c' : (m.errorRate > 0 ? '#e6a23c' : '#67c23a') },
{ label: 'TPS', value: m.actualTps.toFixed(1), color: m.actualTps > 0 ? '#409eff' : undefined },
{ label: '峰值 TPS', value: m.peakTps.toFixed(1), color: undefined },
{ label: '平均响应时间', value: m.avgResponseTime.toFixed(1) + 'ms', color: m.avgResponseTime > 1000 ? '#f56c6c' : (m.avgResponseTime > 300 ? '#e6a23c' : '#409eff') },
{ label: 'P95 响应时间', value: m.p95ResponseTime.toFixed(1) + 'ms', color: m.p95ResponseTime > 1000 ? '#f56c6c' : (m.p95ResponseTime > 500 ? '#e6a23c' : '#409eff') },
{ label: '标准差', value: m.stdDev.toFixed(1) + 'ms', color: m.stdDev > 500 ? '#f56c6c' : undefined },
{ label: 'Apdex', value: m.apdex.toFixed(2), color: m.apdex < 0.85 ? '#f56c6c' : (m.apdex < 0.94 ? '#e6a23c' : '#67c23a') },
]
const coreMetricsHtml = `<div class="section keep-together"><h2>核心指标</h2>${buildMetricGrid(coreMetrics, 4)}</div>`
// A. 执行机资源指标卡片(6 个 → 2 行 × 3 列)
const resourceHtml = r.resourceSummary ? `<div class="section keep-together">
<h2>执行机资源汇总</h2>
${buildMetricGrid([
{ label: 'CPU Avg', value: r.resourceSummary.stats.avgCpuPercent.toFixed(1) + '%', color: r.resourceSummary.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'CPU Max', value: r.resourceSummary.stats.maxCpuPercent.toFixed(1) + '%', color: r.resourceSummary.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Memory Avg', value: r.resourceSummary.stats.avgMemPercent.toFixed(1) + '%', color: r.resourceSummary.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Memory Max', value: r.resourceSummary.stats.maxMemPercent.toFixed(1) + '%', color: r.resourceSummary.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Sent Avg (KB/s)', value: r.resourceSummary.stats.avgSentKbS.toFixed(1) },
{ label: 'Recv Avg (KB/s)', value: r.resourceSummary.stats.avgRecvKbS.toFixed(1) },
], 3)}
</div>` : ''
// B. 目标机资源指标卡片(6 个 → 2 行 × 3 列)
const targetResourceHtml = targetRs ? `<div class="section keep-together">
<h2>目标机资源汇总</h2>
<p class="sub-desc">SSH ${targetRs.host}</p>
${buildMetricGrid([
{ label: 'CPU Avg', value: targetRs.stats.avgCpuPercent.toFixed(1) + '%', color: targetRs.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'CPU Max', value: targetRs.stats.maxCpuPercent.toFixed(1) + '%', color: targetRs.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Memory Avg', value: targetRs.stats.avgMemPercent.toFixed(1) + '%', color: targetRs.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Memory Max', value: targetRs.stats.maxMemPercent.toFixed(1) + '%', color: targetRs.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff' },
{ label: 'Load Avg 1m', value: targetRs.stats.avgLoadAvg1.toFixed(2) },
{ label: 'Load Max 1m', value: targetRs.stats.maxLoadAvg1.toFixed(2) },
], 3)}
</div>` : ''
// C. Java 进程统计表
const javaProcessHtml = targetRs?.javaProcessStats?.length ? `
<div class="section">
<h2>Java 服务进程占用汇总</h2>
<table>
<tr><th>服务进程</th><th>CPU Avg</th><th>CPU Max</th><th>Mem Avg</th><th>Mem Max</th><th>RSS Avg(MB)</th><th>RSS Max(MB)</th></tr>
${targetRs.javaProcessStats.map(jp => `
<tr>
<td>${jp.processName}</td>
<td>${jp.avgCpuPercent.toFixed(1)}%</td>
<td>${jp.maxCpuPercent.toFixed(1)}%</td>
<td>${jp.avgMemPercent.toFixed(1)}%</td>
<td>${jp.maxMemPercent.toFixed(1)}%</td>
<td>${jp.avgRssMb.toFixed(0)} MB</td>
<td>${jp.maxRssMb.toFixed(0)} MB</td>
</tr>`).join('')}
</table>
</div>` : ''
// D. MySQL 监控指标卡片
const javaProcessHtml = targetRs?.javaProcessStats?.length ? `<div class="section keep-together">
<h2>Java 服务进程占用汇总</h2>
<table class="data-table">
<colgroup><col style="width:25%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"><col style="width:12.5%"></colgroup>
<tr><th>服务进程</th><th class="num">CPU Avg</th><th class="num">CPU Max</th><th class="num">Mem Avg</th><th class="num">Mem Max</th><th class="num">RSS Avg</th><th class="num">RSS Max</th></tr>
${targetRs.javaProcessStats.map((jp, idx) => `
<tr${idx % 2 === 1 ? ' class="alt"' : ''}>
<td>${jp.processName}</td>
<td class="num">${jp.avgCpuPercent.toFixed(1)}%</td>
<td class="num">${jp.maxCpuPercent.toFixed(1)}%</td>
<td class="num">${jp.avgMemPercent.toFixed(1)}%</td>
<td class="num">${jp.maxMemPercent.toFixed(1)}%</td>
<td class="num">${jp.avgRssMb.toFixed(0)} MB</td>
<td class="num">${jp.maxRssMb.toFixed(0)} MB</td>
</tr>`).join('')}
</table>
</div>` : ''
// D. MySQL 监控指标卡片(10 个 → 3 行 × 4 列,最后一行 2 个)
const mysql = targetRs?.mysql
const mysqlHtml = mysql?.available ? `
<div class="section">
<h2>MySQL 服务监控</h2>
<p style="color:#909399;font-size:10pt;">MySQL ${mysql.container}(v${mysql.version})</p>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value" style="color:${mysql.threadsConnected > 100 ? '#f56c6c' : '#409eff'}">${mysql.threadsConnected}</div><div class="metric-label">线程连接数</div></div>
<div class="metric-item"><div class="metric-value" style="color:${mysql.threadsRunning > 20 ? '#f56c6c' : '#409eff'}">${mysql.threadsRunning}</div><div class="metric-label">活跃线程</div></div>
<div class="metric-item"><div class="metric-value" style="color:${mysql.slowQueries > 5 ? '#f56c6c' : '#e6a23c'}">${mysql.slowQueries}</div><div class="metric-label">慢查询</div></div>
<div class="metric-item"><div class="metric-value" style="color:${mysql.bufferPoolHitRate < 95 ? '#f56c6c' : '#409eff'}">${mysql.bufferPoolHitRate.toFixed(1)}%</div><div class="metric-label">缓冲池命中率</div></div>
<div class="metric-item"><div class="metric-value">${mysql.maxUsedConnections} / ${mysql.maxConnections}</div><div class="metric-label">连接数 Max Used / Max</div></div>
<div class="metric-item"><div class="metric-value">${formatUptime(mysql.uptime)}</div><div class="metric-label">运行时间</div></div>
</div>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value">${mysql.connections}</div><div class="metric-label">总连接数</div></div>
<div class="metric-item"><div class="metric-value">${mysql.questions}</div><div class="metric-label">QPS Questions</div></div>
<div class="metric-item"><div class="metric-value">${mysql.queries}</div><div class="metric-label">QPS Queries</div></div>
<div class="metric-item"><div class="metric-value">${mysql.bytesReceivedMb.toFixed(1)} MB / ${mysql.bytesSentMb.toFixed(1)} MB</div><div class="metric-label">收发流量</div></div>
</div>
</div>` : ''
const mysqlHtml = mysql?.available ? `<div class="section keep-together">
<h2>MySQL 服务监控</h2>
<p class="sub-desc">MySQL ${mysql.container}(v${mysql.version})</p>
${buildMetricGrid([
{ label: '线程连接数', value: String(mysql.threadsConnected), color: mysql.threadsConnected > 100 ? '#f56c6c' : '#409eff' },
{ label: '活跃线程', value: String(mysql.threadsRunning), color: mysql.threadsRunning > 20 ? '#f56c6c' : '#409eff' },
{ label: '慢查询', value: String(mysql.slowQueries), color: mysql.slowQueries > 5 ? '#f56c6c' : '#e6a23c' },
{ label: '缓冲池命中率', value: mysql.bufferPoolHitRate.toFixed(1) + '%', color: mysql.bufferPoolHitRate < 95 ? '#f56c6c' : '#409eff' },
{ label: '连接数(已用/最大)', value: mysql.maxUsedConnections + ' / ' + mysql.maxConnections },
{ label: '运行时间', value: formatUptime(mysql.uptime) },
{ label: '总连接数', value: String(mysql.connections) },
{ label: 'QPS Questions', value: String(mysql.questions) },
{ label: 'QPS Queries', value: String(mysql.queries) },
{ label: '收发流量', value: mysql.bytesReceivedMb.toFixed(1) + 'MB / ' + mysql.bytesSentMb.toFixed(1) + 'MB' },
], 4)}
</div>` : ''
// ==================== 数据表格(带 colgroup 列宽控制) ====================
// E. 事务步骤明细表
const transactionHtml = transaction?.stepSummaries?.length ? `
<div class="section">
<h2>事务步骤明细</h2>
<p style="color:#909399;font-size:10pt;">事务总次数 ${transaction.totalTransactions} · 成功 ${transaction.successCount} · 失败 ${transaction.failCount} · 平均 ${transaction.avgElapsedMs.toFixed(1)} ms · 最大 ${transaction.maxElapsedMs.toFixed(1)} ms</p>
<table>
<tr><th>步骤名称</th><th>样本数</th><th>成功</th><th>失败</th><th>成功率</th><th>平均(ms)</th><th>最小(ms)</th><th>最大(ms)</th></tr>
${transaction.stepSummaries.map(s => `
<tr>
<td>${s.stepName}</td>
<td>${s.sampleCount}</td>
<td>${s.successCount}</td>
<td style="color:${s.failCount > 0 ? '#f56c6c' : '#909399'}">${s.failCount}</td>
<td>${((s.successCount / (s.sampleCount || 1)) * 100).toFixed(1)}%</td>
<td>${s.avgElapsedMs.toFixed(1)}</td>
<td>${s.minElapsedMs.toFixed(1)}</td>
<td>${s.maxElapsedMs.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>` : ''
// F. 接口级统计明细表(长稳多接口)
const apiSummaryHtml = r.apiSummary?.length ? `
<div class="section">
<h2>接口级统计明细(长稳压测多接口)</h2>
<table>
<tr><th>接口名称</th><th>总请求</th><th>成功</th><th>失败</th><th>错误率</th><th>TPS</th><th>平均(ms)</th><th>最小(ms)</th><th>最大(ms)</th></tr>
${r.apiSummary.map(a => `
<tr>
<td>${a.name}</td>
<td>${a.totalRequests}</td>
<td>${a.successCount}</td>
<td style="color:${a.failCount > 0 ? '#f56c6c' : '#909399'}">${a.failCount}</td>
<td style="color:${a.errorRate > 5 ? '#f56c6c' : '#67c23a'}">${(a.errorRate * 100).toFixed(1)}%</td>
<td>${a.actualTps.toFixed(1)}</td>
<td>${a.avgResponseTime.toFixed(1)}</td>
<td>${a.minResponseTime.toFixed(1)}</td>
<td>${a.maxResponseTime.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>` : ''
// 快照数据表(限前 50 行)
const snapshotsHtml = snapshots.length > 0 ? `
<div class="section">
<h2>快照数据(${snapshots.length} 个采样点)</h2>
<table>
<tr><th>时间(s)</th><th>TPS</th><th>平均响应(ms)</th><th>P95(ms)</th><th>错误率</th></tr>
${snapshots.slice(0, 50).map(s => `
<tr>
<td>${s.elapsed.toFixed(0)}</td>
<td>${s.tps.toFixed(1)}</td>
<td>${s.avgResponseTime.toFixed(1)}</td>
<td>${s.p95ResponseTime.toFixed(1)}</td>
<td>${((s.failCount / ((s.successCount + s.failCount) || 1)) * 100).toFixed(1)}%</td>
</tr>`).join('')}
</table>
</div>` : ''
const transactionHtml = transaction?.stepSummaries?.length ? `<div class="section keep-together">
<h2>事务步骤明细</h2>
<p class="sub-desc">总次数 ${transaction.totalTransactions} · 成功 ${transaction.successCount} · 失败 ${transaction.failCount} · 平均 ${transaction.avgElapsedMs.toFixed(1)} ms · 最大 ${transaction.maxElapsedMs.toFixed(1)} ms</p>
<table class="data-table">
<colgroup><col style="width:22%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"><col style="width:11.1%"></colgroup>
<tr><th>步骤名称</th><th class="num">样本数</th><th class="num">成功</th><th class="num">失败</th><th class="num">成功率</th><th class="num">平均(ms)</th><th class="num">最小(ms)</th><th class="num">最大(ms)</th></tr>
${transaction.stepSummaries.map((s, idx) => `
<tr${idx % 2 === 1 ? ' class="alt"' : ''}>
<td>${s.stepName}</td>
<td class="num">${s.sampleCount}</td>
<td class="num">${s.successCount}</td>
<td class="num" style="color:${s.failCount > 0 ? '#f56c6c' : '#909399'}">${s.failCount}</td>
<td class="num">${((s.successCount / (s.sampleCount || 1)) * 100).toFixed(1)}%</td>
<td class="num">${s.avgElapsedMs.toFixed(1)}</td>
<td class="num">${s.minElapsedMs.toFixed(1)}</td>
<td class="num">${s.maxElapsedMs.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>` : ''
// F. 接口级统计明细表(长稳多接口,9 列
const apiSummaryHtml = r.apiSummary?.length ? `<div class="section">
<h2>接口级统计明细(长稳压测多接口)</h2>
<table class="data-table">
<colgroup><col style="width:20%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"></colgroup>
<tr><th>接口名称</th><th class="num">总请求</th><th class="num">成功</th><th class="num">失败</th><th class="num">错误率</th><th class="num">TPS</th><th class="num">平均(ms)</th><th class="num">最小(ms)</th><th class="num">最大(ms)</th></tr>
${r.apiSummary.map((a, idx) => `
<tr${idx % 2 === 1 ? ' class="alt"' : ''}>
<td>${a.name}</td>
<td class="num">${a.totalRequests}</td>
<td class="num">${a.successCount}</td>
<td class="num" style="color:${a.failCount > 0 ? '#f56c6c' : '#909399'}">${a.failCount}</td>
<td class="num" style="color:${a.errorRate > 5 ? '#f56c6c' : (a.errorRate > 0 ? '#e6a23c' : '#67c23a')}">${(a.errorRate * 100).toFixed(1)}%</td>
<td class="num">${a.actualTps.toFixed(1)}</td>
<td class="num">${a.avgResponseTime.toFixed(1)}</td>
<td class="num">${a.minResponseTime.toFixed(1)}</td>
<td class="num">${a.maxResponseTime.toFixed(1)}</td>
</tr>`).join('')}
</table>
</div>` : ''
// 快照数据表(限前 50 行,长表前强制分页
const snapshotsHtml = snapshots.length > 0 ? `<div class="section section-break">
<h2>快照数据(${snapshots.length} 个采样点)</h2>
<table class="data-table">
<colgroup><col style="width:15%"><col style="width:20%"><col style="width:20%"><col style="width:20%"><col style="width:25%"></colgroup>
<tr><th class="num">时间(s)</th><th class="num">TPS</th><th class="num">平均响应(ms)</th><th class="num">P95(ms)</th><th class="num">错误率</th></tr>
${snapshots.slice(0, 50).map((s, idx) => `
<tr${idx % 2 === 1 ? ' class="alt"' : ''}>
<td class="num">${s.elapsed.toFixed(0)}</td>
<td class="num">${s.tps.toFixed(1)}</td>
<td class="num">${s.avgResponseTime.toFixed(1)}</td>
<td class="num">${s.p95ResponseTime.toFixed(1)}</td>
<td class="num">${((s.failCount / ((s.successCount + s.failCount) || 1)) * 100).toFixed(1)}%</td>
</tr>`).join('')}
</table>
</div>` : ''
return `
<h1>性能测试报告</h1>
<div class="section">
<div class="section keep-together">
<h2>执行摘要</h2>
<table>
<table class="summary-table">
<colgroup><col style="width:20%"><col style="width:80%"></colgroup>
<tr><td class="label-cell">任务名称</td><td>${r.summary.taskName}</td></tr>
<tr><td class="label-cell">目标 URL</td><td>${r.summary.targetUrl}</td></tr>
<tr><td class="label-cell">HTTP 方法</td><td>${r.summary.method}</td></tr>
......@@ -1371,41 +1385,29 @@ function buildReportHtml(r: PerformanceReportResponse): string {
</table>
</div>
<div class="section">
<h2>核心指标</h2>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value">${m.totalRequests}</div><div class="metric-label">总请求数</div></div>
<div class="metric-item"><div class="metric-value">${m.successCount}</div><div class="metric-label">成功数</div></div>
<div class="metric-item"><div class="metric-value">${m.failCount}</div><div class="metric-label">失败数</div></div>
<div class="metric-item"><div class="metric-value">${(m.errorRate * 100).toFixed(2)}%</div><div class="metric-label">错误率</div></div>
<div class="metric-item"><div class="metric-value">${m.actualTps.toFixed(1)}</div><div class="metric-label">TPS</div></div>
<div class="metric-item"><div class="metric-value">${m.peakTps.toFixed(1)}</div><div class="metric-label">峰值 TPS</div></div>
<div class="metric-item"><div class="metric-value">${m.avgResponseTime.toFixed(1)}ms</div><div class="metric-label">平均响应时间</div></div>
<div class="metric-item"><div class="metric-value">${m.p95ResponseTime.toFixed(1)}ms</div><div class="metric-label">P95 响应时间</div></div>
<div class="metric-item"><div class="metric-value">${m.stdDev.toFixed(1)}ms</div><div class="metric-label">标准差</div></div>
<div class="metric-item"><div class="metric-value">${m.apdex.toFixed(2)}</div><div class="metric-label">Apdex</div></div>
</div>
</div>
${coreMetricsHtml}
<div class="section">
<div class="section keep-together">
<h2>响应时间分位数</h2>
<table>
<tr><th>分位数</th><th>值 (ms)</th></tr>
<tr><td>P50</td><td>${m.p50ResponseTime.toFixed(1)}</td></tr>
<tr><td>P90</td><td>${m.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td>${m.p95ResponseTime.toFixed(1)}</td></tr>
<tr><td>P99</td><td>${m.p99ResponseTime.toFixed(1)}</td></tr>
<table class="data-table">
<colgroup><col style="width:30%"><col style="width:70%"></colgroup>
<tr><th>分位数</th><th class="num">值 (ms)</th></tr>
<tr><td>P50</td><td class="num">${m.p50ResponseTime.toFixed(1)}</td></tr>
<tr class="alt"><td>P90</td><td class="num">${m.p90ResponseTime.toFixed(1)}</td></tr>
<tr><td>P95</td><td class="num">${m.p95ResponseTime.toFixed(1)}</td></tr>
<tr class="alt"><td>P99</td><td class="num">${m.p99ResponseTime.toFixed(1)}</td></tr>
</table>
</div>
<div class="section">
<div class="section keep-together">
<h2>状态码分布</h2>
<table>
<tr><th>状态码</th><th>数量</th><th>占比</th></tr>
<tr><td>2xx</td><td>${m.status2xx}</td><td>${((m.status2xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>3xx</td><td>${m.status3xx}</td><td>${((m.status3xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td>${m.status4xx}</td><td>${((m.status4xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>5xx</td><td>${m.status5xx}</td><td>${((m.status5xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<table class="data-table">
<colgroup><col style="width:20%"><col style="width:30%"><col style="width:50%"></colgroup>
<tr><th>状态码</th><th class="num">数量</th><th class="num">占比</th></tr>
<tr><td>2xx</td><td class="num">${m.status2xx}</td><td class="num">${((m.status2xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr class="alt"><td>3xx</td><td class="num">${m.status3xx}</td><td class="num">${((m.status3xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr><td>4xx</td><td class="num">${m.status4xx}</td><td class="num">${((m.status4xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
<tr class="alt"><td>5xx</td><td class="num">${m.status5xx}</td><td class="num">${((m.status5xx / (m.totalRequests || 1)) * 100).toFixed(1)}%</td></tr>
</table>
</div>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论