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

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 文档:报告导出质量优化需求文档 + 计划执行文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a154ce32
# 需求文档:报告导出质量优化(内容缺失与格式完善)
> **生成时间**:2026-08-25
> **关联文档**:`_PRD_请求详情查看与报告导出优化.md`、`HANDOFF_性能测试.md`
> **版本**:v1.0
---
## 1. 背景与问题
### 1.1 问题现象
用户反馈当前报告导出(Word / PDF)的文件内容看起来「都是截图」。经过代码审查,实际根因并非导出文件只有截图,而是**导出内容严重缺失**——大量文本内容(指标卡片、资源汇总、MySQL 监控、事务步骤明细等)未被包含在导出 HTML 中,导致视觉上图表图片占据了主导地位,看起来像是"只有截图"。
### 1.2 当前导出实现回顾
`ReportPanel.vue``buildReportHtml()` 函数构建导出 HTML 时**仅包含**以下内容:
| 模块 | 当前导出 | 页面展示 | 状态 |
|------|----------|----------|------|
| 执行摘要表 | ✅ 有 | ✅ 有 | ✅ |
| 核心指标卡片 | ✅ 有 | ✅ 有 | ✅ |
| 响应时间分位数表 | ✅ 有 | ✅ 有 | ✅ |
| 状态码分布表 | ✅ 有 | ✅ 有 | ✅ |
| 趋势图表(TPS/RT/延迟/带宽) | ✅ 有(base64 图片) | ✅ 有 | ✅ |
| 执行机资源图表 | ✅ 有(base64 图片) | ✅ 有 | ✅ |
| 目标机资源图表 | ✅ 有(base64 图片) | ✅ 有 | ✅ |
| 快照数据表(前 50 条) | ✅ 有 | ✅ 有 | ✅ |
| **执行机资源指标卡片** | ❌ 缺失 | ✅ 有 | 🔴 |
| **目标机资源指标卡片** | ❌ 缺失 | ✅ 有 | 🔴 |
| **Java 进程统计表** | ❌ 缺失 | ✅ 有 | 🔴 |
| **MySQL 监控指标** | ❌ 缺失 | ✅ 有 | 🔴 |
| **事务步骤瀑布图** | ❌ 缺失 | ✅ 有 | 🔴 |
| **事务步骤明细表** | ❌ 缺失 | ✅ 有 | 🔴 |
| **接口级统计明细表** | ❌ 缺失 | ✅ 有 | 🔴 |
| **AI 分析结果** | ❌ 缺失 | ✅ 有 | 🔴 |
`ProjectReport.vue``buildReportHtml()`**完全没有图表图片**,且同样缺少资源/事务/MySQL 等指标。
### 1.3 根本原因
1. **`buildReportHtml()` 与页面渲染不同步**:页面展示内容由 Vue 模板驱动(`<template>` 内的条件渲染 `v-if`),导出 HTML 则是在 `buildReportHtml()` 中手写 HTML 字符串拼接。新增页面模块(资源监控、MySQL、事务瀑布等)时,只更新了 Vue 模板,漏掉了导出函数。
2. **仅 4 个图表被导出**`buildReportHtml()` 只调用了 `getChartImage()` 获取 4 个基本图表(TPS、RT、延迟、带宽)+ 2 个资源图表。瀑布图、Java 进程 CPU 图、网络图均未导出。
3. **Word 格式兼容性**:当前 Word 导出使用 `application/msword` MIME 的 HTML 格式,但导出的 HTML 结构与 Word 渲染兼容性未验证——表格样式、图片缩放、分页等可能存在渲染问题。
4. **PDF 打印延迟不足**`setTimeout(500)` 对于大图加载可能不够,导致图片未完全渲染就触发打印。
---
## 2. 需求范围
### 2.1 目标
对齐报告页面的可视内容,确保 **Word/PDF 导出的内容与浏览器页面展示一致**,不缺模块、不缺数据。
### 2.2 FR-1: 导出内容补全(ReportPanel)
| 属性 | 说明 |
|------|------|
| 优先级 | P0 |
| 描述 | `ReportPanel.vue``buildReportHtml()` 补全所有缺失的页面模块 |
**需补全的模块**
**A. 执行机资源指标卡片**
- CPU Avg / CPU Max / Memory Avg / Memory Max / Sent Avg / Recv Avg
- 与页面条件一致:仅在 `report.resourceSummary` 存在时显示
**B. 目标机资源指标卡片**
- CPU Avg / CPU Max / Memory Avg / Memory Max / Load Avg 1m / Load Max 1m
- 与页面条件一致:仅在 `report.targetResourceSummary` 存在时显示
**C. Java 进程统计表**
- 进程名 / CPU 平均/最大 / 内存平均/最大 / RSS 平均/最大
- 条件:`report.targetResourceSummary.javaProcessStats` 非空
**D. MySQL 监控指标卡片**
- 线程连接数 / 活跃线程 / 慢查询 / 缓冲池命中率 / 连接数 / 运行时间 / 总连接数 / QPS Questions / QPS Queries / 收发流量
- 条件:`report.targetResourceSummary.mysql?.available` 为 true
**E. 事务步骤明细表**
- 步骤名称 / 样本数 / 成功 / 失败 / 成功率 / 平均/最小/最大耗时
- 条件:`report.transactionSummary?.stepSummaries` 非空
**F. 接口级统计明细表**
- 接口名称 / 总请求 / 成功 / 失败 / 错误率 / TPS / 平均/最小/最大耗时
- 条件:`report.apiSummary` 非空
### 2.3 FR-2: 导出图表补全(ReportPanel)
| 属性 | 说明 |
|------|------|
| 优先级 | P0 |
| 描述 | 补全 `buildReportHtml()` 中缺失的图表图片导出 |
**需补全的图表**
| 图表 | 页面变量 | 导出条件 |
|------|---------|----------|
| 执行机资源趋势图 | `resourceChart` | `report.resourceSummary` 存在时 |
| 网络趋势图 | `networkChart` | `report.resourceSummary` 存在时 |
| 目标机资源趋势图 | `targetResourceChart` | `report.targetResourceSummary` 存在时 |
| Java 进程 CPU 趋势图 | `javaProcessChart` | `javaProcessSeries` 非空时 |
| 事务瀑布图 | `waterfallChart` | `transactionSummary?.stepSummaries` 非空时 |
### 2.4 FR-3: 导出内容补全(ProjectReport)
| 属性 | 说明 |
|------|------|
| 优先级 | P1 |
| 描述 | `ProjectReport.vue` 补全缺失的执行机/目标机资源指标、MySQL 监控、事务明细等(同 FR-1 A~E,不含图表) |
### 2.5 FR-4: 导出格式优化
| 属性 | 说明 |
|------|------|
| 优先级 | P1 |
| 描述 | 优化 Word/PDF 导出的排版和渲染质量 |
**具体优化项**
**A. 打印延迟优化**
-`setTimeout(500)` 改为动态等待:监听页面 `onload` 或使用 `requestAnimationFrame` 循环检测图片加载完成,最长等待 5s
- 或使用 `setTimeout(2000)` 确保大图加载完毕
**B. Word 兼容性**
-`.doc` 导出额外生成 Word 兼容的 `mso-` 样式(表格边框、单元格间距、字体映射)
- 添加 `<!--[if gte mso 9]>` 条件注释适配 Word 渲染
**C. 打印分页优化**
- 为每个大章节(资源汇总、MySQL、事务明细等)添加 `page-break-before``page-break-after` 控制
- 图表容器添加 `page-break-inside: avoid` 防止跨页断裂
**D. 响应式图片缩放**
- 图表图片默认宽度 100%,但限制最大宽度 800px 以防 Word 中失真
- 长图(如 MySQL 指标卡片行)考虑横向适配
### 2.6 FR-5: 图片加载状态提示
| 属性 | 说明 |
|------|------|
| 优先级 | P2 |
| 描述 | 导出 PDF 时,在图中图片加载期间显示 loading 提示,避免用户误以为空白页 |
**实现思路**
-`buildExportHtml()``contentBody` 开头添加一个加载提示层
- 图片全部加载完成后通过 `window.onload` 移除提示
- 或简单地在标题下方添加「正在加载图表图片,请稍候…」文字
---
## 3. 非功能需求
| 类型 | 要求 |
|------|------|
| 兼容性 | 导出 Word 在 WPS / Microsoft Word 中均可正常打开,表格不串列、图片不溢出 |
| 性能 | 导出 PDF 触发打印前等待时间 ≤ 3s(含图片加载);导出 Word 点击即下载 |
| 可维护性 | `buildReportHtml()` 与页面模板保持同步,新增页面模块时需同步更新导出函数 |
| 数据安全 | 导出内容中的敏感字段(Authorization、密码等)必须脱敏,与页面展示一致 |
---
## 4. 不在范围
- 导出 Excel / CSV 格式 —— 后续按需开通;
- 后端 reportlab / python-docx 服务端渲染 —— 前端方案可满足需求时不做;
- 导出报告自动邮件发送 —— 后续迭代;
- 导出报告模板自定义(用户选择导出哪些模块)—— 后续迭代;
- 报告对比(两次执行 diff)导出 —— 已在 P2 规划。
---
## 5. 影响范围
### 前端
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `frontend/src/views/performance/ReportPanel.vue` | MODIFY | `buildReportHtml()` 补全资源/MySQL/事务/接口明细等全部模块 + 补全图表导出 |
| `frontend/src/views/performance/ProjectReport.vue` | MODIFY | `buildReportHtml()` 补全资源/MySQL/事务等模块 |
| `frontend/src/utils/exportReport.ts` | MODIFY | 打印等待延迟优化;Word 兼容样式增强;分页控制优化 |
### 文档
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化.md` | ADD | 本需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化_计划执行.md` | ADD | 计划执行文档 |
---
## 6. 验收标准
### FR-1 导出内容补全(ReportPanel)
- [ ] Word 导出的报告包含执行机资源指标卡片(CPU/Memory/带宽),数值与页面一致;
- [ ] 包含目标机资源指标卡片(CPU/Memory/Load),数值与页面一致;
- [ ] 包含 Java 进程统计表(有数据时),内容与页面一致;
- [ ] 包含 MySQL 监控指标卡片(有数据时),内容与页面一致;
- [ ] 包含事务步骤明细表(有数据时),内容与页面一致;
- [ ] 包含接口级统计明细表(有数据时),内容与页面一致。
### FR-2 导出图表补全
- [ ] Word 导出包含执行机资源趋势图(有数据时);
- [ ] 包含网络趋势图(有数据时);
- [ ] 包含目标机资源趋势图(有数据时);
- [ ] 包含 Java 进程 CPU 趋势图(有数据时);
- [ ] 包含事务瀑布图(有数据时);
- [ ] PDF 导出同样包含上述图表,图片显示完整不缺失。
### FR-3 ProjectReport 导出补全
- [ ] 合并报告 Word 导出包含执行机/目标机资源指标、MySQL 监控、事务明细等(有数据时)。
### FR-4 格式优化
- [ ] Word 导出在 WPS / Microsoft Word 中表格对齐、图片不溢出;
- [ ] PDF 打印预览分页合理,图表不跨页断裂;
- [ ] PDF 导出图片全部加载完成后再触发打印,图片不缺失。
---
## 7. 术语
| 术语 | 说明 |
|------|------|
| 导出 HTML | `buildReportHtml()` 函数构建的用于导出 Word/PDF 的 HTML 字符串 |
| base64 图片 | ECharts `getDataURL()` 输出的 data:image/png;base64 格式图片 |
| 执行机资源 | 运行压测引擎的机器(Resource Summary) |
| 目标机资源 | 被压测的目标服务器(Target Resource Summary,通过 SSH 采集) |
| 事务瀑布图 | Transaction Waterfall Chart,展示多步骤事务中各步骤的耗时分布 |
\ No newline at end of file
# 报告导出质量优化 - 计划执行文档
> **关联 PRD**:`_PRD_报告导出质量优化.md`
> **文档版本**:v1.0
> **制定日期**:2026-08-25
> **执行分支**:`platform-auto-test`
> **优先级**:P0(内容缺失)/ P1(格式优化)
---
## 一、执行目标
1. **补全导出内容**:对齐 `ReportPanel.vue``ProjectReport.vue` 的页面展示内容,确保 Word/PDF 导出包含所有页面模块(资源指标、MySQL 监控、事务步骤、接口统计等);
2. **补全导出图表**:将缺失的 5 个图表(网络趋势、目标机资源、Java 进程 CPU、瀑布图)纳入导出;
3. **优化导出格式**:改善 Word 兼容性、PDF 打印分页、图片加载等待。
---
## 二、实施原则
1. **条件渲染同步**:导出 HTML 的条件判断与 Vue 模板 `v-if` 完全一致,不出现页面有但导出无的情况;
2. **增量补全,不改结构**:仅在 `buildReportHtml()` 中添加新模块,不重构既有导出逻辑;
3. **图表加载等待**:PDF 导出确保图片全部加载完毕再触发打印,避免空白图片;
4. **Word 兼容标记**:为 Word 导出添加 `mso-` 样式和条件注释,提升在 WPS/MS Office 中的渲染质量。
---
## 三、阶段计划
### Phase 0:文档准备
| 工作项 | 文件 | 结果 |
|--------|------|------|
| 编写需求文档 | `_PRD_报告导出质量优化.md` | 明确范围、FR、验收标准 |
| 编写本计划 | `_PRD_报告导出质量优化_计划执行.md` | 明确代码阶段、文件变更、验证方式 |
### Phase 1:ReportPanel 导出内容补全
#### 1.1 补全资源指标卡片
**文件**`frontend/src/views/performance/ReportPanel.vue``buildReportHtml()` 函数
在核心指标卡片之后,添加条件渲染的执行机资源指标卡片 HTML:
```html
${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">${r.resourceSummary.stats.maxCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Max</div></div>
<div class="metric-item"><div class="metric-value">${r.resourceSummary.stats.avgMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Avg</div></div>
<div class="metric-item"><div class="metric-value">${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>` : ''}
```
#### 1.2 补全目标机资源指标卡片
在资源卡片之后,添加条件渲染的目标机资源指标卡片:
```html
${r.targetResourceSummary ? `
<div class="section">
<h2>目标机资源汇总</h2>
<p style="color:#909399;font-size:10pt;">SSH ${r.targetResourceSummary.host}</p>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.stats.avgCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.stats.avgCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.stats.maxCpuPercent > 85 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.stats.maxCpuPercent.toFixed(1)}%</div><div class="metric-label">CPU Max</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.stats.avgMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.stats.avgMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Avg</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.stats.maxMemPercent > 85 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.stats.maxMemPercent.toFixed(1)}%</div><div class="metric-label">Memory Max</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.stats.avgLoadAvg1.toFixed(2)}</div><div class="metric-label">Load Avg 1m</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.stats.maxLoadAvg1.toFixed(2)}</div><div class="metric-label">Load Max 1m</div></div>
</div>
</div>` : ''}
```
#### 1.3 补全 Java 进程统计表
```html
${r.targetResourceSummary?.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>
${r.targetResourceSummary.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)}</td>
<td>${jp.maxRssMb.toFixed(0)}</td>
</tr>`).join('')}
</table>
</div>` : ''}
```
#### 1.4 补全 MySQL 监控指标卡片
```html
${r.targetResourceSummary?.mysql?.available ? `
<div class="section">
<h2>MySQL 服务监控</h2>
<p style="color:#909399;font-size:10pt;">MySQL ${r.targetResourceSummary.mysql.container}(v${r.targetResourceSummary.mysql.version})</p>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.mysql.threadsConnected > 100 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.mysql.threadsConnected}</div><div class="metric-label">线程连接数</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.mysql.threadsRunning > 20 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.mysql.threadsRunning}</div><div class="metric-label">活跃线程</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.mysql.slowQueries > 5 ? '#f56c6c' : '#e6a23c'}">${r.targetResourceSummary.mysql.slowQueries}</div><div class="metric-label">慢查询</div></div>
<div class="metric-item"><div class="metric-value" style="color:${r.targetResourceSummary.mysql.bufferPoolHitRate < 95 ? '#f56c6c' : '#409eff'}">${r.targetResourceSummary.mysql.bufferPoolHitRate.toFixed(1)}%</div><div class="metric-label">缓冲池命中率</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.mysql.maxUsedConnections} / ${r.targetResourceSummary.mysql.maxConnections}</div><div class="metric-label">连接数</div></div>
<div class="metric-item"><div class="metric-value">${formatUptime(r.targetResourceSummary.mysql.uptime)}</div><div class="metric-label">运行时间</div></div>
</div>
<div class="metric-grid">
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.mysql.connections}</div><div class="metric-label">总连接数</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.mysql.questions}</div><div class="metric-label">QPS Questions</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.mysql.queries}</div><div class="metric-label">QPS Queries</div></div>
<div class="metric-item"><div class="metric-value">${r.targetResourceSummary.mysql.bytesReceivedMb.toFixed(1)} MB / ${r.targetResourceSummary.mysql.bytesSentMb.toFixed(1)} MB</div><div class="metric-label">收发流量</div></div>
</div>
</div>` : ''}
```
#### 1.5 补全事务步骤明细表
```html
${r.transactionSummary?.stepSummaries?.length ? `
<div class="section">
<h2>事务步骤明细</h2>
<table>
<tr><th>步骤名称</th><th>样本数</th><th>成功</th><th>失败</th><th>成功率</th><th>平均(ms)</th><th>最小(ms)</th><th>最大(ms)</th></tr>
${r.transactionSummary.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>` : ''}
```
#### 1.6 补全接口级统计明细表
```html
${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>` : ''}
```
### Phase 2:ReportPanel 导出图表补全
**文件**`frontend/src/views/performance/ReportPanel.vue``buildReportHtml()` 函数
在现有图表 HTML 之后,添加缺失图表的 base64 图片导出:
```typescript
// 额外图表
const networkImg = getChartImage(networkChart)
const targetResourceImg = getChartImage(targetResourceChart)
const javaProcessImg = getChartImage(javaProcessChart)
const waterfallImg = getChartImage(waterfallChart)
// 仅在对应数据存在时导出
if (r.resourceSummary) {
if (networkImg) chartsHtml += `<h2>网络趋势</h2><img class="chart-image" src="${networkImg}" alt="网络趋势" />`
}
if (r.targetResourceSummary) {
if (targetResourceImg) chartsHtml += `<h2>目标机资源趋势</h2><img class="chart-image" src="${targetResourceImg}" alt="目标机资源趋势" />`
}
if (r.targetResourceSummary?.javaProcessSeries?.length) {
if (javaProcessImg) chartsHtml += `<h2>Java 进程 CPU 趋势</h2><img class="chart-image" src="${javaProcessImg}" alt="Java 进程 CPU 趋势" />`
}
if (r.transactionSummary?.stepSummaries?.length) {
if (waterfallImg) chartsHtml += `<h2>事务步骤瀑布图</h2><img class="chart-image" src="${waterfallImg}" alt="事务步骤瀑布图" />`
}
```
### Phase 3:ProjectReport 导出内容补全
**文件**`frontend/src/views/performance/ProjectReport.vue``buildReportHtml()` 函数
「合并报告」页面同样需要补全资源指标、MySQL 监控、事务明细等文本内容(不含图表,因为 ProjectReport 页面本身不展示图表)。
**注意**`ProjectReportResponse` 的类型结构与 `PerformanceReportResponse` 不同,需要通过 `r.taskSummaries` 汇总数据。具体需确认 `ProjectReportResponse` 是否包含 `resourceSummary` / `targetResourceSummary` / `transactionSummary` / `apiSummary` 字段。
### Phase 4:导出格式优化
**文件**`frontend/src/utils/exportReport.ts`
#### 4.1 打印延迟优化
`exportPdf()` 中的 `setTimeout(500)` 改为动态等待策略:
```typescript
// 使用 Promise 等待所有图片加载完成
function waitForImages(win: Window, timeoutMs = 5000): Promise<void> {
return new Promise((resolve) => {
const imgs = win.document.querySelectorAll('img')
if (imgs.length === 0) {
resolve()
return
}
let loaded = 0
const checkDone = () => {
if (loaded >= imgs.length) {
resolve()
return
}
// 每 100ms 检查一次
setTimeout(checkDone, 100)
}
imgs.forEach(img => {
if (img.complete) {
loaded++
} else {
img.onload = () => { loaded++ }
img.onerror = () => { loaded++ } // 加载失败也计数
}
})
// 超时保护
setTimeout(resolve, timeoutMs)
// 开始检查
checkDone()
})
}
// 在 exportPdf 中使用:
await waitForImages(win)
win.focus()
win.print()
```
#### 4.2 Word 兼容样式增强
`buildExportHtml()` 的 CSS 中,添加 Word 兼容的 `mso-` 样式:
```css
/* Word 兼容 */
table {
mso-border-alt: thin solid #dcdfe6;
mso-border-between: thin solid #dcdfe6;
border-collapse: collapse;
}
td, th {
mso-border-alt: thin solid #dcdfe6;
padding: 4px 8px;
}
```
#### 4.3 打印分页优化
`buildExportHtml()``@media print` 样式中添加:
```css
.section {
page-break-inside: avoid;
}
h2 {
page-break-after: avoid;
}
.section-break {
page-break-before: always;
}
```
### Phase 5:验证与修复
| 工作项 | 验证方式 | 通过标准 |
|--------|----------|---------|
| ReportPanel Word 导出 | 下载 .doc 用 WPS 和 Word 打开 | 包含所有模块(资源/MySQL/事务/接口),数值正确,图片完整 |
| ReportPanel PDF 导出 | 浏览器打印预览 + 保存 PDF 检查 | 内容完整,分页合理,图片不缺失 |
| ProjectReport Word 导出 | 同上 | 同样包含所有文本模块 |
| 内容一致性 | 对比页面展示与导出内容 | 每个模块条件一致,数据一致 |
| 兼容性 | WPS / Word 分别打开 | 表格不串列,图片不溢出 |
---
## 四、文件变更清单
### 前端
| 文件 | 变更类型 | 关联 FR |
|------|---------|---------|
| `frontend/src/views/performance/ReportPanel.vue` | MODIFY | FR-1, FR-2 |
| — `buildReportHtml()` 补全资源指标卡片 | ADD | FR-1A |
| — `buildReportHtml()` 补全目标机资源指标卡片 | ADD | FR-1B |
| — `buildReportHtml()` 补全 Java 进程统计表 | ADD | FR-1C |
| — `buildReportHtml()` 补全 MySQL 监控指标 | ADD | FR-1D |
| — `buildReportHtml()` 补全事务步骤明细表 | ADD | FR-1E |
| — `buildReportHtml()` 补全接口统计明细表 | ADD | FR-1F |
| — `buildReportHtml()` 补全网络/目标机/Java/瀑布图 | ADD | FR-2 |
| `frontend/src/views/performance/ProjectReport.vue` | MODIFY | FR-3 |
| — `buildReportHtml()` 补全资源/MySQL/事务等模块 | ADD | FR-3 |
| `frontend/src/utils/exportReport.ts` | MODIFY | FR-4 |
| — `exportPdf()` 图片加载等待逻辑 | MODIFY | FR-4A |
| — `buildExportHtml()` Word 兼容样式 | ADD | FR-4B |
| — `buildExportHtml()` 打印分页样式 | ADD | FR-4C |
### 文档
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化.md` | ADD | 本需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_报告导出质量优化_计划执行.md` | ADD | 本计划执行文档 |
| `Docs/PRD/性能测试/HANDOFF_性能测试.md` | MODIFY | 会话收尾更新进度 |
---
## 五、验证用例
### TC-1: 基础报告导出
1. 打开一个含资源监控数据的性能测试报告
2. 点击「导出 Word」→ 下载 `.doc` 文件
3. 用 WPS 打开 → 检查包含:摘要表、核心指标、分位数、状态码、图表图片、资源指标卡片、快照表
4. 点击「导出 PDF」→ 浏览器打印预览 → 检查内容完整
### TC-2: 含目标机资源报告导出
1. 打开一个含目标机资源监控 + MySQL 监控的报告
2. 导出 Word → 检查包含:目标机 CPU/Memory/Load 指标卡片、MySQL 指标卡片
3. 导出 PDF → 检查图片和文本都完整
### TC-3: 事务任务报告导出
1. 打开一个事务压测的报告(含 `transactionSummary`
2. 导出 Word → 检查包含:事务步骤明细表、瀑布图
3. 导出 PDF → 检查瀑布图完整显示
### TC-4: 长稳多接口报告导出
1. 打开一个长稳压测报告(含 `apiSummary`
2. 导出 Word → 检查包含:接口级统计明细表
### TC-5: 无资源数据报告导出
1. 打开一个未开启资源监控的报告
2. 导出 Word → 检查资源指标卡片不出现(无空白占位)
### TC-6: 合并报告导出
1. 打开项目合并报告页面
2. 导出 Word → 检查包含所有任务明细 + 资源指标(有数据时)
---
## 六、风险与应对
| 风险 | 影响 | 概率 | 应对 |
|------|------|------|------|
| base64 图片过大导致 Word 文件体积巨大 | 用户体验 | 中 | 限制 `pixelRatio: 2` 已防过大;可考虑 `pixelRatio: 1` 降级 |
| 图片加载等待逻辑在 iframe 中失效 | PDF 导出空白 | 低 | 增加超时兜底(5s 强制打印) |
| Word 中表格样式错乱 | 兼容性 | 中 | 使用内联样式(避免类选择器在 Word 中不生效) |
| 导出 HTML 过大导致浏览器卡顿 | 性能 | 低 | 控制快照表行数(当前已限制 50 行) |
\ No newline at end of file
...@@ -2369,6 +2369,9 @@ class PerformanceExecutor: ...@@ -2369,6 +2369,9 @@ class PerformanceExecutor:
if error_msg and len(error_msg) > 500: if error_msg and len(error_msg) > 500:
error_msg = error_msg[:500] error_msg = error_msg[:500]
# 对 URL 去前缀:去掉 https://IP 部分,只保留路径
clean_url = self._strip_url_prefix(url)
return { return {
"method": method, "method": method,
"url": url, "url": url,
...@@ -2388,7 +2391,7 @@ class PerformanceExecutor: ...@@ -2388,7 +2391,7 @@ class PerformanceExecutor:
"success": (200 <= status < 400) and (assert_ok is None or assert_ok), "success": (200 <= status < 400) and (assert_ok is None or assert_ok),
"request_index": request_index, "request_index": request_index,
"thread_idx": thread_idx, "thread_idx": thread_idx,
"api_name": api_name, "api_name": api_name or clean_url,
"ts": datetime.utcnow().isoformat(timespec="milliseconds"), "ts": datetime.utcnow().isoformat(timespec="milliseconds"),
} }
...@@ -2428,6 +2431,28 @@ class PerformanceExecutor: ...@@ -2428,6 +2431,28 @@ class PerformanceExecutor:
return data return data
return data return data
@staticmethod
def _strip_url_prefix(url: str) -> str:
"""
去除 URL 的前缀(protocol://host:port),只保留路径部分。
用于请求明细/接口名显示:`https://192.168.5.44/meetingV3/api/message/book`
→ `/meetingV3/api/message/book`(用户要求去掉 `https://IP` 前缀,2026-08-25)。
Args:
url: 完整 URL
Returns:
str: 去除协议与主机前缀后的路径
"""
if not url:
return url
# 匹配 scheme://[userinfo@]host[:port],去掉后保留路径(含 query)
m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+", url)
if m:
return url[m.end():] or "/"
return url
async def _sleep_with_check(self, seconds: float) -> None: async def _sleep_with_check(self, seconds: float) -> None:
""" """
分段睡眠,期间持续检查停止标志,保证 stop() 能快速生效 分段睡眠,期间持续检查停止标志,保证 stop() 能快速生效
......
...@@ -1241,6 +1241,7 @@ ...@@ -1241,6 +1241,7 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
...@@ -2059,13 +2060,15 @@ ...@@ -2059,13 +2060,15 @@
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/lodash/-/lodash-4.18.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/lodash-es/-/lodash-es-4.18.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
...@@ -2337,6 +2340,7 @@ ...@@ -2337,6 +2340,7 @@
"integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"immutable": "^5.1.5", "immutable": "^5.1.5",
...@@ -2399,6 +2403,7 @@ ...@@ -2399,6 +2403,7 @@
"integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
...@@ -2423,6 +2428,7 @@ ...@@ -2423,6 +2428,7 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
...@@ -2436,6 +2442,7 @@ ...@@ -2436,6 +2442,7 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-5.4.21.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
...@@ -2501,6 +2508,7 @@ ...@@ -2501,6 +2508,7 @@
"version": "3.5.39", "version": "3.5.39",
"resolved": "https://mirrors.cloud.tencent.com/npm/vue/-/vue-3.5.39.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.39", "@vue/compiler-dom": "3.5.39",
"@vue/compiler-sfc": "3.5.39", "@vue/compiler-sfc": "3.5.39",
......
...@@ -34,7 +34,7 @@ export function exportWord(title: string, htmlContent: string, filename: string) ...@@ -34,7 +34,7 @@ export function exportWord(title: string, htmlContent: string, filename: string)
/** /**
* 导出 PDF(通过浏览器打印) * 导出 PDF(通过浏览器打印)
* *
* 原理:在新窗口渲染报告 HTML,调用 window.print(), * 原理:在新窗口渲染报告 HTML,等待所有图表图片加载完成后调用 window.print(),
* 用户选择「另存为 PDF」完成导出。 * 用户选择「另存为 PDF」完成导出。
* *
* @param title 文档标题 * @param title 文档标题
...@@ -50,12 +50,57 @@ export function exportPdf(title: string, htmlContent: string): void { ...@@ -50,12 +50,57 @@ export function exportPdf(title: string, htmlContent: string): void {
win.document.write(fullHtml) win.document.write(fullHtml)
win.document.title = title win.document.title = title
win.document.close() win.document.close()
// 等待图片加载完成后打印 // 等待所有图片加载完成后再触发打印(超时兜底 5s)
setTimeout(() => { waitForImages(win, 5000).then(() => {
win.focus() win.focus()
win.print() win.print()
// 不关闭窗口,让用户自行关闭 // 不关闭窗口,让用户自行关闭
}, 500) })
}
/**
* 等待窗口内所有图片加载完成
*
* base64 图片通常同步可用(complete=true),大图或异常时依赖 onload/onerror 计数。
* 无论是否全部加载,超过 timeoutMs 后强制 resolve,避免打印永不触发。
*
* @param win 目标窗口
* @param timeoutMs 超时毫秒数
*/
function waitForImages(win: Window, timeoutMs: number): Promise<void> {
return new Promise((resolve) => {
let settled = false
const done = () => {
if (!settled) {
settled = true
resolve()
}
}
// 超时兜底
const timer = setTimeout(done, timeoutMs)
const imgs = Array.from(win.document.querySelectorAll('img'))
if (imgs.length === 0) {
clearTimeout(timer)
done()
return
}
let loaded = 0
const markLoaded = () => {
loaded++
if (loaded >= imgs.length) {
clearTimeout(timer)
done()
}
}
imgs.forEach((img) => {
if (img.complete) {
markLoaded()
} else {
img.addEventListener('load', markLoaded, { once: true })
img.addEventListener('error', markLoaded, { once: true })
}
})
})
} }
/** /**
...@@ -117,6 +162,19 @@ function buildExportHtml(title: string, contentBody: string): string { ...@@ -117,6 +162,19 @@ function buildExportHtml(title: string, contentBody: string): string {
img { max-width: 100% !important; page-break-inside: avoid; } img { max-width: 100% !important; page-break-inside: avoid; }
table { page-break-inside: avoid; } table { page-break-inside: avoid; }
h1, h2, h3, h4 { page-break-after: avoid; } h1, h2, h3, h4 { page-break-after: avoid; }
.section { page-break-inside: avoid; }
.section-break { page-break-before: always; }
}
/* Word 兼容 */
table {
mso-border-alt: thin solid #dcdfe6;
mso-border-between: thin solid #dcdfe6;
border-collapse: collapse;
}
td, th {
mso-border-alt: thin solid #dcdfe6;
padding: 4px 8px;
} }
/* 屏幕样式 */ /* 屏幕样式 */
......
...@@ -428,8 +428,12 @@ function buildReportHtml(r: ProjectReportResponse): string { ...@@ -428,8 +428,12 @@ function buildReportHtml(r: ProjectReportResponse): string {
<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.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.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.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.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">${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> </div>
</div> </div>
......
...@@ -1185,25 +1185,175 @@ function exportPdfFromReport() { ...@@ -1185,25 +1185,175 @@ function exportPdfFromReport() {
function buildReportHtml(r: PerformanceReportResponse): string { function buildReportHtml(r: PerformanceReportResponse): string {
const m = r.metrics const m = r.metrics
const snapshots = r.snapshots || [] const snapshots = r.snapshots || []
const targetRs = r.targetResourceSummary
const transaction = r.transactionSummary
// ==================== 图表 base64 图片 ====================
let chartsHtml = '' let chartsHtml = ''
// 嵌入图表图片
const tpsImg = getChartImage(tpsChart) const tpsImg = getChartImage(tpsChart)
const rtImg = getChartImage(rtChart) const rtImg = getChartImage(rtChart)
const latencyImg = getChartImage(latencyChart) const latencyImg = getChartImage(latencyChart)
const bandwidthImg = getChartImage(bandwidthChart) const bandwidthImg = getChartImage(bandwidthChart)
const resourceImg = getChartImage(resourceChart)
const networkImg = getChartImage(networkChart)
const targetResourceImg = getChartImage(targetResourceChart)
const javaProcessImg = getChartImage(javaProcessChart)
const waterfallImg = getChartImage(waterfallChart)
if (tpsImg) chartsHtml += `<h2>TPS 趋势</h2><img class="chart-image" src="${tpsImg}" alt="TPS 趋势" />` 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 (rtImg) chartsHtml += `<h2>响应时间趋势</h2><img class="chart-image" src="${rtImg}" alt="响应时间趋势" />`
if (latencyImg) chartsHtml += `<h2>延迟分解趋势</h2><img class="chart-image" src="${latencyImg}" 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 (bandwidthImg) chartsHtml += `<h2>带宽趋势</h2><img class="chart-image" src="${bandwidthImg}" alt="带宽趋势" />`
// 资源图表 // 执行机资源:图表 + 网络趋势(与页面 v-if 条件一致)
const resourceImg = getChartImage(resourceChart) if (r.resourceSummary) {
if (resourceImg) chartsHtml += `<h2>执行机资源趋势</h2><img class="chart-image" src="${resourceImg}" alt="执行机资源趋势" />` 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="网络趋势" />`
}
const targetResourceImg = getChartImage(targetResourceChart) // 目标机资源:趋势图 + Java 进程 CPU 趋势(有数据时才导出)
if (targetRs) {
if (targetResourceImg) chartsHtml += `<h2>目标机资源趋势</h2><img class="chart-image" src="${targetResourceImg}" alt="目标机资源趋势" />` if (targetResourceImg) chartsHtml += `<h2>目标机资源趋势</h2><img class="chart-image" src="${targetResourceImg}" alt="目标机资源趋势" />`
if (targetRs.javaProcessSeries?.length && javaProcessImg) {
chartsHtml += `<h2>Java 服务 CPU 趋势</h2><img class="chart-image" src="${javaProcessImg}" alt="Java 服务 CPU 趋势" />`
}
}
// 事务瀑布图(有步骤数据时才导出)
if (transaction?.stepSummaries?.length && waterfallImg) {
chartsHtml += `<h2>事务步骤瀑布图</h2><img class="chart-image" src="${waterfallImg}" alt="事务步骤瀑布图" />`
}
// ==================== 文字模块(与页面 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>` : ''
// 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 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>` : ''
// 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>` : ''
return ` return `
<h1>性能测试报告</h1> <h1>性能测试报告</h1>
...@@ -1261,21 +1411,19 @@ function buildReportHtml(r: PerformanceReportResponse): string { ...@@ -1261,21 +1411,19 @@ function buildReportHtml(r: PerformanceReportResponse): string {
${chartsHtml} ${chartsHtml}
${snapshots.length > 0 ? ` ${resourceHtml}
<div class="section">
<h2>快照数据(${snapshots.length} 个采样点)</h2> ${targetResourceHtml}
<table>
<tr><th>时间(s)</th><th>TPS</th><th>平均响应(ms)</th><th>P95(ms)</th><th>错误率</th></tr> ${javaProcessHtml}
${snapshots.slice(0, 50).map(s => `
<tr> ${mysqlHtml}
<td>${s.elapsed.toFixed(0)}</td>
<td>${s.tps.toFixed(1)}</td> ${transactionHtml}
<td>${s.avgResponseTime.toFixed(1)}</td>
<td>${s.p95ResponseTime.toFixed(1)}</td> ${apiSummaryHtml}
<td>${((s.failCount / ((s.successCount + s.failCount) || 1)) * 100).toFixed(1)}%</td>
</tr>`).join('')} ${snapshotsHtml}
</table>
</div>` : ''}
` `
} }
......
...@@ -103,7 +103,11 @@ ...@@ -103,7 +103,11 @@
@row-click="openDetail" @row-click="openDetail"
> >
<el-table-column label="#" width="50" type="index" /> <el-table-column label="#" width="50" type="index" />
<el-table-column prop="api_name" label="接口名" min-width="120" show-overflow-tooltip /> <el-table-column label="接口名" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ displayApiName(row) }}
</template>
</el-table-column>
<el-table-column prop="method" label="方法" width="70"> <el-table-column prop="method" label="方法" width="70">
<template #default="{ row }"> <template #default="{ row }">
<el-tag <el-tag
...@@ -369,6 +373,18 @@ function copyText(text: string) { ...@@ -369,6 +373,18 @@ function copyText(text: string) {
}) })
} }
/** 显示接口名:优先取 api_name,为空则从 URL 去前缀截取 */
function displayApiName(row: { api_name?: string; url?: string }): string {
if (row.api_name) return row.api_name
if (row.url) {
// 去掉 protocol://host:port 前缀,只保留路径
const m = row.url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)/)
if (m) return m[1] || '/'
return row.url
}
return '-'
}
/** 重置筛选 */ /** 重置筛选 */
function resetFilters() { function resetFilters() {
filters.value = { status: '', success: '', keyword: '', minResponseMs: '', maxResponseMs: '' } filters.value = { status: '', success: '', keyword: '', minResponseMs: '', maxResponseMs: '' }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论