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

feat(performance): 前端合并报告页面 ProjectReport.vue + 项目详情页查看合并报告入口

- 新增 ProjectReport.vue 合并报告页面(聚合指标卡片 4×16 + 分位数表 + 状态码分布 + 各任务明细表)
- 注册合并报告路由 /performance/project-report
- ProjectDetail.vue 批量执行完成后显示查看合并报告按钮 + 历史报告入口
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 866acd45
......@@ -193,6 +193,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/performance/ReportPanel.vue'),
meta: { title: '报告查看' }
},
{
path: 'project-report',
name: 'PerfProjectReport',
component: () => import('@/views/performance/ProjectReport.vue'),
meta: { title: '合并报告' }
},
{
path: 'presets',
name: 'PerfPresets',
......
......@@ -26,6 +26,12 @@
<el-button v-if="selectedIds.length > 0" type="warning" :loading="runningBatch" @click="handleBatchRun">
<el-icon><VideoPlay /></el-icon>批量执行({{ selectedIds.length }}
</el-button>
<el-button v-if="lastReportId" type="success" @click="viewMergedReport(lastReportId)">
<el-icon><Document /></el-icon>查看合并报告
</el-button>
<el-button @click="viewHistoryReports">
<el-icon><List /></el-icon>历史报告
</el-button>
<el-button @click="loadTasks">
<el-icon><Refresh /></el-icon>刷新
</el-button>
......@@ -182,7 +188,7 @@
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, VideoPlay, Refresh } from '@element-plus/icons-vue'
import { ArrowLeft, VideoPlay, Refresh, Document, List } from '@element-plus/icons-vue'
import { getProject, listTasks, runTask, stopTask, deleteTask, runProjectAll, batchRunTasks } from '@/api/performance'
import type { PerformanceProject, PerformanceTaskListItem } from '@/types/performance'
......@@ -214,6 +220,7 @@ const batchTotalCount = ref(0)
const batchCurrentTaskName = ref('')
const batchSuccessCount = ref(0)
const batchFailCount = ref(0)
const lastReportId = ref<string | null>(null) // 最近一次批量执行生成的合并报告 ID
const batchProgressPercent = computed(() => {
if (batchTotalCount.value === 0) return 0
return Math.round((batchCurrentIndex.value / batchTotalCount.value) * 100)
......@@ -312,10 +319,14 @@ async function handleRunAll() {
if (confirmed === null) return
runningAll.value = true
lastReportId.value = null
showBatchProgress(tasks.value.length)
try {
const res = await runProjectAll(projectId.value)
ElMessage.success(res.message || '项目已开始执行')
if (res.reportId) {
lastReportId.value = res.reportId
}
} catch (e: any) {
ElMessage.error('执行失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
......@@ -338,10 +349,14 @@ async function handleBatchRun() {
if (confirmed === null) return
runningBatch.value = true
lastReportId.value = null
showBatchProgress(selectedIds.value.length)
try {
const res = await batchRunTasks({ taskIds: selectedIds.value, projectId: projectId.value })
ElMessage.success(res.message || '批量执行完成')
if (res.reportId) {
lastReportId.value = res.reportId
}
selectedIds.value = []
tableRef.value?.clearSelection()
await loadTasks()
......@@ -415,6 +430,16 @@ function modeLabel(mode: string) {
return map[mode] || mode
}
/** 跳转合并报告 */
function viewMergedReport(reportId: string) {
router.push(`/performance/project-report?reportId=${reportId}&projectId=${projectId.value}`)
}
/** 跳转历史报告列表 */
function viewHistoryReports() {
router.push(`/performance/project-report?projectId=${projectId.value}`)
}
/** 格式化时间 */
function formatTime(t: string) {
return t?.replace('T', ' ').substring(0, 19) || ''
......
<!--
组件名称:ProjectReport.vue
组件描述:性能测试合并报告页面 - 聚合指标卡片 / 分位数表 / 状态码分布 / 各任务明细
@author czj
@date 2026-08-20
-->
<template>
<div class="project-report-page">
<!-- 加载中 -->
<el-skeleton v-if="loading" :rows="8" animated />
<template v-else-if="report">
<!-- 顶部操作栏 -->
<div class="report-header">
<div class="header-left">
<el-button @click="goBack" text>
<el-icon><ArrowLeft /></el-icon>返回项目详情
</el-button>
<h2 class="page-title">合并报告</h2>
<el-select
v-if="hasHistory && historyReports.length > 0"
:model-value="historyReportId"
placeholder="选择历史报告"
size="default"
style="width: 260px"
@change="onHistoryChange"
>
<el-option
v-for="r in historyReports"
:key="r.id"
:label="historyLabel(r)"
:value="r.id"
/>
</el-select>
<el-tag v-if="report.batchId" type="info" size="small">
{{ report.batchId }}
</el-tag>
</div>
<div class="header-right">
<el-button type="primary" :loading="loading" @click="loadReport">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button @click="exportReport">
<el-icon><Download /></el-icon>导出 JSON
</el-button>
</div>
</div>
<!-- 执行摘要 -->
<el-descriptions title="执行摘要" :column="3" border class="summary-block">
<el-descriptions-item label="报告 ID">{{ report.id }}</el-descriptions-item>
<el-descriptions-item label="批次 ID">{{ report.batchId || '-' }}</el-descriptions-item>
<el-descriptions-item label="生成时间">{{ formatTime(report.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="总任务数">{{ report.taskSummaries.length }}</el-descriptions-item>
<el-descriptions-item label="总请求数">{{ report.summary.totalRequests }}</el-descriptions-item>
<el-descriptions-item label="实际总耗时">{{ report.summary.durationActual != null ? report.summary.durationActual.toFixed(1) + 's' : '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 第一行:请求统计 -->
<el-row :gutter="12" class="metrics-row">
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.totalRequests }}</div>
<div class="metric-label">总请求数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.successCount }}</div>
<div class="metric-label">总成功数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.failCount }}</div>
<div class="metric-label">总失败数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: (report.summary.errorRate * 100) > 5 ? '#f56c6c' : '#67c23a' }">
{{ (report.summary.errorRate * 100).toFixed(2) }}%
</div>
<div class="metric-label">整体错误率</div>
</el-card>
</el-col>
</el-row>
<!-- 第二行:吞吐 + 响应时间 -->
<el-row :gutter="12" class="metrics-row">
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.actualTps.toFixed(1) }}</div>
<div class="metric-label">整体 TPS</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.peakTps.toFixed(1) }}</div>
<div class="metric-label">峰值 TPS</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.avgResponseTime.toFixed(1) }}ms</div>
<div class="metric-label">平均响应时间</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: rtPerfColor(report.summary.p95ResponseTime) }">
{{ report.summary.p95ResponseTime.toFixed(1) }}ms
</div>
<div class="metric-label">P95 响应时间</div>
</el-card>
</el-col>
</el-row>
<!-- 第三行:稳定性 + 极值 -->
<el-row :gutter="12" class="metrics-row">
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.stdDev.toFixed(1) }}ms</div>
<div class="metric-label">响应时间标准差</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value" :style="{ color: apdexColor(report.summary.apdex) }">
{{ report.summary.apdex.toFixed(2) }}
</div>
<div class="metric-label">Apdex 满意度</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.minResponseTime.toFixed(1) }}ms</div>
<div class="metric-label">最小响应时间</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.maxResponseTime.toFixed(1) }}ms</div>
<div class="metric-label">最大响应时间</div>
</el-card>
</el-col>
</el-row>
<!-- 第四行:带宽 -->
<el-row :gutter="12" class="metrics-row">
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ formatBytes(report.summary.totalSentBytes) }}</div>
<div class="metric-label">总发送数据</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ formatBytes(report.summary.totalReceivedBytes) }}</div>
<div class="metric-label">总接收数据</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.durationActual != null ? report.summary.durationActual.toFixed(1) + 's' : '-' }}</div>
<div class="metric-label">实际总耗时</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="never" class="metric-card">
<div class="metric-value">{{ report.summary.actualTps.toFixed(1) }}</div>
<div class="metric-label">整体 TPS</div>
</el-card>
</el-col>
</el-row>
<!-- 响应时间分位数 -->
<el-row :gutter="12">
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>响应时间分位数</span></template>
<el-table :data="quantileRows" size="small">
<el-table-column prop="label" label="分位数" width="120" />
<el-table-column prop="value" label="值 (ms)" />
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>状态码分布</span></template>
<el-table :data="statusRows" size="small">
<el-table-column prop="code" label="状态码" width="120" />
<el-table-column prop="count" label="数量" />
<el-table-column prop="percent" label="占比" />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 各任务明细表 -->
<el-card shadow="never" class="chart-card">
<template #header><span>各任务明细</span></template>
<el-table :data="report.taskSummaries" size="small" stripe>
<el-table-column prop="taskName" label="任务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalRequests" label="请求数" width="90" />
<el-table-column prop="successCount" label="成功" width="80" />
<el-table-column prop="failCount" label="失败" width="80" />
<el-table-column label="错误率" width="90">
<template #default="{ row }">
<span :style="{ color: (row.errorRate * 100) > 5 ? '#f56c6c' : '#67c23a' }">
{{ (row.errorRate * 100).toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="平均响应时间" width="120">
<template #default="{ row }">{{ row.avgResponseTime.toFixed(1) }}ms</template>
</el-table-column>
<el-table-column label="TPS" width="90">
<template #default="{ row }">{{ row.actualTps.toFixed(1) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewTaskReport(row.taskId)">
查看报告
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<el-empty v-else description="暂无合并报告数据" />
</div>
</template>
<script setup lang="ts">
/**
* 性能测试合并报告页面
*
* 功能:展示项目维度的聚合指标卡片 + 各任务明细列表
* 路由:/performance/project-report?projectId=xxx&reportId=xxx
*/
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Refresh, Download } from '@element-plus/icons-vue'
import { getProjectReport, getBatchReport, getProject, getProjectReports } from '@/api/performance'
import type { ProjectReportResponse, PerformanceProject, ProjectReportListResponse } from '@/types/performance'
const route = useRoute()
const router = useRouter()
const projectId = computed(() => route.query.projectId as string)
const reportId = computed(() => route.query.reportId as string)
const loading = ref(false)
const project = ref<PerformanceProject | null>(null)
const report = ref<ProjectReportResponse | null>(null)
// 历史报告列表(同一项目下的历史合并报告切换)
const historyReports = ref<ProjectReportListResponse['items']>([])
const hasHistory = computed(() => historyReports.value.length > 0)
const historyReportId = computed(() => report.value?.id || '')
/** 返回项目详情页 */
function goBack() {
if (projectId.value) {
router.push(`/performance/projects/${projectId.value}`)
} else {
router.push('/performance/projects')
}
}
/** 加载合并报告 */
async function loadReport() {
loading.value = true
try {
let res: ProjectReportResponse
if (reportId.value) {
// 按 reportId 获取
res = await getBatchReport(reportId.value)
} else if (projectId.value) {
// 获取项目最新合并报告
res = await getProjectReport(projectId.value)
} else {
ElMessage.error('缺少 projectId 或 reportId 参数')
return
}
report.value = res
// 加载后自动拉取历史报告列表
loadHistoryReports()
} catch (e: any) {
ElMessage.error('加载合并报告失败: ' + (e.response?.data?.detail || e.message || ''))
} finally {
loading.value = false
}
}
/** 加载历史报告列表(供切换用) */
async function loadHistoryReports() {
if (!projectId.value) return
try {
const res = await getProjectReports(projectId.value, { page: 1, pageSize: 50 })
historyReports.value = res.items
} catch {
// 历史列表非必需
}
}
/** 历史报告切换 */
function onHistoryChange(val: string) {
router.push(`/performance/project-report?reportId=${val}&projectId=${projectId.value}`)
}
/** 历史报告下拉标签 */
function historyLabel(r: ProjectReportResponse) {
const time = r.createdAt?.replace('T', ' ').substring(0, 16) || ''
return `${time} · ${r.summary.totalRequests} 请求 · ${r.taskSummaries.length} 任务`
}
/** 加载项目信息 */
async function loadProject() {
if (!projectId.value) return
try {
project.value = await getProject(projectId.value)
} catch {
// 项目信息非必需
}
}
/** 跳转到单任务报告 */
function viewTaskReport(taskId: string) {
router.push(`/performance/report?taskId=${taskId}`)
}
/** 导出 JSON */
function exportReport() {
if (!report.value) return
const blob = new Blob([JSON.stringify(report.value, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `report_${report.value.id || 'merged'}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('报告已导出')
}
/** 分位数表行 */
const quantileRows = computed(() => {
if (!report.value) return []
const s = report.value.summary
return [
{ label: 'P50', value: s.p50ResponseTime.toFixed(1) },
{ label: 'P90', value: s.p90ResponseTime.toFixed(1) },
{ label: 'P95', value: s.p95ResponseTime.toFixed(1) },
{ label: 'P99', value: s.p99ResponseTime.toFixed(1) },
]
})
/** 状态码分布表行 */
const statusRows = computed(() => {
if (!report.value) return []
const s = report.value.summary
const total = s.status2xx + s.status3xx + s.status4xx + s.status5xx || 1
return [
{ code: '2xx', count: s.status2xx, percent: (s.status2xx / total * 100).toFixed(1) + '%' },
{ code: '3xx', count: s.status3xx, percent: (s.status3xx / total * 100).toFixed(1) + '%' },
{ code: '4xx', count: s.status4xx, percent: (s.status4xx / total * 100).toFixed(1) + '%' },
{ code: '5xx', count: s.status5xx, percent: (s.status5xx / total * 100).toFixed(1) + '%' },
]
})
/** 格式化时间 */
function formatTime(t: string | null) {
return t?.replace('T', ' ').substring(0, 19) || ''
}
/** 格式化字节数 */
function formatBytes(bytes: number) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return size.toFixed(1) + ' ' + units[i]
}
/** 响应时间性能颜色 */
function rtPerfColor(ms: number) {
if (ms < 200) return '#67c23a'
if (ms < 500) return '#e6a23c'
return '#f56c6c'
}
/** Apdex 颜色 */
function apdexColor(val: number) {
if (val >= 0.94) return '#67c23a'
if (val >= 0.85) return '#e6a23c'
return '#f56c6c'
}
/** 状态标签类型 */
function statusType(status: string) {
const map: Record<string, string> = { completed: 'success', running: 'primary', pending: 'info', failed: 'danger', cancelled: 'warning' }
return map[status] || 'info'
}
/** 状态标签文字 */
function statusLabel(status: string) {
const map: Record<string, string> = { completed: '已完成', running: '运行中', pending: '等待中', failed: '失败', cancelled: '已取消' }
return map[status] || status
}
onMounted(() => {
loadProject()
loadReport()
})
// 路由参数变化时重新加载(历史报告切换)
watch(reportId, (newVal, oldVal) => {
if (newVal && newVal !== oldVal) {
loadReport()
}
})
</script>
<style scoped>
.project-report-page {
padding: 0;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
}
.header-right {
display: flex;
gap: 12px;
}
}
.summary-block {
margin-bottom: 20px;
}
.metrics-row {
margin-bottom: 12px;
}
.metric-card {
text-align: center;
padding: 8px 0;
background: #fafafa;
.metric-value {
font-size: 24px;
font-weight: 700;
color: #303133;
line-height: 1.4;
}
.metric-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
}
.chart-card {
margin-bottom: 16px;
}
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论