提交 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
...@@ -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>
......
...@@ -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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论