All checks were successful
CI / test (pull_request) Successful in 3m55s
4.1 新增三个评估场景(急诊分诊、慢病管理、健康咨询),各 3 个用例,
全部使用无模型绑定依赖的规则;急诊场景编码 <20s 延迟验收标准
4.2 ModelGateway 由每次请求新建 httpx.AsyncClient 改为单实例共享客户端
(复用 TCP/TLS 连接),引擎与模型连通性测试端点负责关闭;
tutu 通道已具备同等优化,无需改动
4.3 Reports.tsx 单次报告顶部新增上线评估横幅:go/no-go/conditional
三态 banner + 各验收标准达标情况标签
版本号升至 1.3.1(v1.3.1-final)。
门禁:pytest tests/unit 709 passed;ruff 全绿;
前端 tsc --noEmit + vitest 232 passed。
附带修复 RunList 测试时区缺陷:started_at 用 UTC 日期构造,
本地 00:00-08:00 之间会被默认"今天"过滤器排除导致误报失败。
655 lines
26 KiB
TypeScript
655 lines
26 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { useSearchParams } from 'react-router-dom'
|
||
import {
|
||
Alert, Button, Card, Col, Collapse, Descriptions, Empty, Row, Segmented,
|
||
Select, Space, Spin, Statistic, Table, Tag, Tooltip, Badge, message,
|
||
} from 'antd'
|
||
import {
|
||
CheckCircleOutlined, CloseCircleOutlined, ClearOutlined,
|
||
DownloadOutlined, UserOutlined, RobotOutlined, ReloadOutlined,
|
||
DiffOutlined, FileMarkdownOutlined,
|
||
} from '@ant-design/icons'
|
||
import { reportsApi, runsApi, type Run } from '../api'
|
||
import PageWrapper from '../components/PageWrapper'
|
||
import { colors, statusColors, triggerColors, triggerLabels } from '../tokens'
|
||
import { formatDateTime } from '../utils/date'
|
||
import { passRateColor } from '../utils/colors'
|
||
import { fmtPct } from '../utils/format'
|
||
import { useResource } from '../hooks/useResource'
|
||
|
||
interface TurnData {
|
||
sent_text: string
|
||
reply_text: string | null
|
||
latency_ms: number | null
|
||
}
|
||
|
||
interface RuleResultData {
|
||
rule_type: string
|
||
passed: boolean
|
||
score: number | null
|
||
reason: string
|
||
}
|
||
|
||
interface CaseReport {
|
||
case_id: string
|
||
connectivity: boolean
|
||
turns: TurnData[]
|
||
results: RuleResultData[]
|
||
}
|
||
|
||
interface CriterionResult {
|
||
criterion: string
|
||
threshold: number
|
||
actual: number
|
||
passed: boolean
|
||
detail: string
|
||
}
|
||
|
||
interface GoNoGoVerdict {
|
||
decision: string
|
||
summary: string
|
||
criteria_results: CriterionResult[]
|
||
}
|
||
|
||
interface Report {
|
||
run_id: string
|
||
target_name: string
|
||
scenario_name: string
|
||
scenario_version?: number
|
||
triggered_by?: string
|
||
status: string
|
||
started_at: string
|
||
completed_at: string | null
|
||
summary: {
|
||
total_cases: number
|
||
passed_cases: number
|
||
failed_cases: number
|
||
total_rules: number
|
||
passed_rules: number
|
||
pass_rate: number
|
||
connectivity_cases: number
|
||
judged_pass_rate: number | null
|
||
}
|
||
go_no_go?: GoNoGoVerdict
|
||
cases: CaseReport[]
|
||
}
|
||
|
||
interface CompareResult {
|
||
run_a: { run_id: string; target_name: string; scenario_name: string; scenario_version?: number; triggered_by?: string; status: string; started_at: string; summary: Report['summary'] }
|
||
run_b: { run_id: string; target_name: string; scenario_name: string; scenario_version?: number; triggered_by?: string; status: string; started_at: string; summary: Report['summary'] }
|
||
delta: { pass_rate: number; passed_cases: number; passed_rules: number }
|
||
cases: Array<{
|
||
case_id: string
|
||
connectivity: boolean
|
||
run_a_passed: boolean | null
|
||
run_b_passed: boolean | null
|
||
changed: boolean
|
||
run_a_results: RuleResultData[]
|
||
run_b_results: RuleResultData[]
|
||
}>
|
||
changed_cases: number
|
||
}
|
||
|
||
type ViewMode = 'single' | 'compare'
|
||
|
||
export default function ReportsPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const runQuery = searchParams.get('run') ?? ''
|
||
|
||
const [scenarioFilter, setScenarioFilter] = useState<string>('')
|
||
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
||
const [compareRunId, setCompareRunId] = useState<string>('')
|
||
const [report, setReport] = useState<Report | null>(null)
|
||
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [viewMode, setViewMode] = useState<ViewMode>('single')
|
||
|
||
// Keep-alive tabs never remount: refresh whenever this tab is re-activated
|
||
// so runs triggered elsewhere (e.g. AI assistant) show up.
|
||
const { data: runsData, reload: reloadRuns } = useResource(
|
||
() => runsApi.list().then((r) => r.data.filter((run) => run.status === 'completed')),
|
||
{ tabPath: '/reports' },
|
||
)
|
||
const runs = useMemo(() => runsData ?? [], [runsData])
|
||
|
||
const loadReport = async (runId: string) => {
|
||
setSelectedRunId(runId)
|
||
setCompareResult(null)
|
||
setLoading(true)
|
||
try {
|
||
const res = await reportsApi.get(runId)
|
||
setReport(res.data as Report)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (runQuery && runQuery !== selectedRunId) {
|
||
loadReport(runQuery)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [runQuery])
|
||
|
||
const handleView = (runId: string) => {
|
||
setSearchParams({ run: runId })
|
||
loadReport(runId)
|
||
// 对比报告要求同场景同版本:A 变更后若 B 不可比则清空
|
||
const a = runs.find((r) => r.id === runId)
|
||
const b = runs.find((r) => r.id === compareRunId)
|
||
if (a && b && (a.scenario_id !== b.scenario_id
|
||
|| (a.scenario_version ?? 1) !== (b.scenario_version ?? 1))) setCompareRunId('')
|
||
}
|
||
|
||
const handleCompare = async () => {
|
||
if (!selectedRunId || !compareRunId) return
|
||
setLoading(true)
|
||
try {
|
||
const res = await reportsApi.compare(selectedRunId, compareRunId)
|
||
setCompareResult(res.data as CompareResult)
|
||
} catch (e: any) {
|
||
const detail = e?.response?.data?.detail
|
||
if (detail) message.error(detail)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const clearSelection = () => {
|
||
setSelectedRunId('')
|
||
setCompareRunId('')
|
||
setReport(null)
|
||
setCompareResult(null)
|
||
setSearchParams({})
|
||
}
|
||
|
||
const resetFilters = () => {
|
||
setScenarioFilter('')
|
||
clearSelection()
|
||
}
|
||
|
||
// 场景筛选项:从 run 列表聚合(含各场景 run 数量与出现过的考纲版本)
|
||
const scenarioOptions = useMemo(() => {
|
||
const map = new Map<string, { name: string; count: number; versions: Set<number> }>()
|
||
for (const r of runs) {
|
||
const entry = map.get(r.scenario_id)
|
||
if (entry) {
|
||
entry.count += 1
|
||
entry.versions.add(r.scenario_version ?? 1)
|
||
} else {
|
||
map.set(r.scenario_id, {
|
||
name: r.scenario_name || r.scenario_id.slice(0, 8),
|
||
count: 1,
|
||
versions: new Set([r.scenario_version ?? 1]),
|
||
})
|
||
}
|
||
}
|
||
return [...map.entries()].map(([id, s]) => {
|
||
const versions = [...s.versions].sort((a, b) => a - b).map((v) => `v${v}`).join('/')
|
||
return { value: id, label: `${s.name}(${s.count} · ${versions})` }
|
||
})
|
||
}, [runs])
|
||
|
||
const filteredRuns = useMemo(
|
||
() => (scenarioFilter ? runs.filter((r) => r.scenario_id === scenarioFilter) : runs),
|
||
[runs, scenarioFilter],
|
||
)
|
||
|
||
const buildOption = (r: Run) => {
|
||
const passRate = r.summary?.pass_rate
|
||
const pct = passRate != null ? `${Math.round(passRate * 100)}%` : '-'
|
||
const scenario = r.scenario_name || r.scenario_id.slice(0, 8)
|
||
const version = `v${r.scenario_version ?? 1}`
|
||
const target = r.target_name || r.target_id.slice(0, 8)
|
||
const time = r.started_at ? formatDateTime(r.started_at) : ''
|
||
const trigger = r.triggered_by ?? 'manual'
|
||
return {
|
||
value: r.id,
|
||
searchText: `${scenario} ${version} ${target} ${time} ${r.id} ${triggerLabels[trigger] ?? trigger}`.toLowerCase(),
|
||
label: (
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis' }}>{scenario}</span>
|
||
<Tag color="geekblue" style={{ marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>{version}</Tag>
|
||
<span style={{ color: colors.textMuted }}>· {target}</span>
|
||
<span style={{ color: colors.textMuted, fontSize: 12 }}>{time}</span>
|
||
<span style={{
|
||
color: passRateColor(passRate ?? 0),
|
||
fontSize: 12, fontWeight: 600,
|
||
}}>{pct}</span>
|
||
<Tag color={triggerColors[trigger] ?? 'default'} style={{ marginRight: 0, fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||
{triggerLabels[trigger] ?? trigger}
|
||
</Tag>
|
||
</span>
|
||
),
|
||
}
|
||
}
|
||
|
||
const runSelectOptions = filteredRuns.map(buildOption)
|
||
|
||
// 报告 B 只能选与报告 A 同场景同版本(同考纲)的 run
|
||
const selectedRun = runs.find((r) => r.id === selectedRunId)
|
||
const compareOptions = runs
|
||
.filter((r) =>
|
||
r.id !== selectedRunId
|
||
&& selectedRun
|
||
&& r.scenario_id === selectedRun.scenario_id
|
||
&& (r.scenario_version ?? 1) === (selectedRun.scenario_version ?? 1))
|
||
.map(buildOption)
|
||
|
||
const optionFilter = (input: string, opt?: { searchText?: string }) =>
|
||
(opt?.searchText ?? '').includes(input.toLowerCase())
|
||
|
||
return (
|
||
<PageWrapper title="评测报告" description="查看评测结果详情与统计分析" inline fullHeight>
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{/* 选择栏:两行布局,避免对比模式下控件换行错乱 */}
|
||
<div style={{
|
||
padding: '8px 16px', flexShrink: 0,
|
||
borderBottom: `1px solid ${colors.border}`,
|
||
background: colors.bgSubtle,
|
||
display: 'flex', flexDirection: 'column', gap: 8,
|
||
}}>
|
||
{/* 第一行:视图模式 + 场景筛选 + 重置/刷新 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Segmented
|
||
value={viewMode}
|
||
onChange={(v) => { setViewMode(v as ViewMode); setCompareResult(null) }}
|
||
options={[
|
||
{ label: '单次报告', value: 'single' },
|
||
{ label: '对比报告', value: 'compare', icon: <DiffOutlined /> },
|
||
]}
|
||
/>
|
||
<Select
|
||
style={{ width: 320 }}
|
||
value={scenarioFilter || undefined}
|
||
placeholder={`全部场景(${runs.length})`}
|
||
allowClear
|
||
onChange={(v) => setScenarioFilter(v ?? '')}
|
||
options={scenarioOptions}
|
||
popupMatchSelectWidth={false}
|
||
/>
|
||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||
<Tooltip title="重置筛选与选择">
|
||
<Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}>重置</Button>
|
||
</Tooltip>
|
||
<Tooltip title="刷新列表">
|
||
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reloadRuns()} />
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 第二行:报告选择 + 操作 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<Select
|
||
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
|
||
placeholder={viewMode === 'compare' ? '选择报告 A' : '选择已完成的评测记录'}
|
||
value={selectedRunId || undefined}
|
||
onChange={(v) => (v ? handleView(v) : clearSelection())}
|
||
allowClear
|
||
showSearch
|
||
filterOption={optionFilter}
|
||
options={runSelectOptions}
|
||
popupMatchSelectWidth={false}
|
||
/>
|
||
|
||
{viewMode === 'compare' && (
|
||
<>
|
||
<span style={{ color: colors.textMuted, fontSize: 12, flexShrink: 0 }}>vs</span>
|
||
<Tooltip title={selectedRunId ? '仅可选择与报告 A 相同场景的记录' : '请先选择报告 A'}>
|
||
<Select
|
||
style={{ flex: 1, minWidth: 320, maxWidth: 680 }}
|
||
placeholder="选择报告 B(同场景)"
|
||
value={compareRunId || undefined}
|
||
onChange={(v) => { setCompareRunId(v ?? ''); if (!v) setCompareResult(null) }}
|
||
allowClear
|
||
showSearch
|
||
disabled={!selectedRunId}
|
||
filterOption={optionFilter}
|
||
options={compareOptions}
|
||
popupMatchSelectWidth={false}
|
||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有同场景同版本的其他评测记录" />}
|
||
/>
|
||
</Tooltip>
|
||
<Button
|
||
type="primary"
|
||
icon={<DiffOutlined />}
|
||
disabled={!selectedRunId || !compareRunId}
|
||
onClick={handleCompare}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
对比
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{viewMode === 'single' && report && (
|
||
<Space style={{ flexShrink: 0 }}>
|
||
<Button icon={<DownloadOutlined />} onClick={() => reportsApi.download(selectedRunId, 'html')}>
|
||
导出 HTML
|
||
</Button>
|
||
<Button icon={<FileMarkdownOutlined />} onClick={() => reportsApi.download(selectedRunId, 'markdown')}>
|
||
导出 MD
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 报告内容区 */}
|
||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px' }}>
|
||
<Spin spinning={loading}>
|
||
{viewMode === 'compare'
|
||
? <CompareView result={compareResult} />
|
||
: <SingleReportView report={report} />
|
||
}
|
||
</Spin>
|
||
</div>
|
||
</div>
|
||
</PageWrapper>
|
||
)
|
||
}
|
||
|
||
// ── Single Report View ───────────────────────────────────────────────────
|
||
|
||
function SingleReportView({ report }: { report: Report | null }) {
|
||
if (!report) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
|
||
<Empty description="请选择一个评测记录查看报告" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{report.go_no_go && <GoNoGoBanner verdict={report.go_no_go} />}
|
||
|
||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||
<Col xs={24} sm={12} md={6}>
|
||
<Card><Statistic title="总用例数" value={report.summary.total_cases} /></Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} md={6}>
|
||
<Card>
|
||
<Statistic title="通过用例" value={report.summary.passed_cases}
|
||
valueStyle={{ color: statusColors.completed }} prefix={<CheckCircleOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} md={6}>
|
||
<Card>
|
||
<Statistic title="失败用例" value={report.summary.failed_cases}
|
||
valueStyle={{ color: statusColors.failed }} prefix={<CloseCircleOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} md={6}>
|
||
<Card>
|
||
<Statistic title="通过率" value={report.summary.pass_rate * 100} precision={1} suffix="%"
|
||
valueStyle={{ color: passRateColor(report.summary.pass_rate) }} />
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{report.summary.connectivity_cases > 0 && (
|
||
<Alert type="info" showIcon style={{ marginBottom: 16 }}
|
||
message={`本次运行含 ${report.summary.connectivity_cases} 个连通用例(未配置判定标准,收到回复即通过)` +
|
||
(report.summary.judged_pass_rate != null
|
||
? `,判定型通过率 ${fmtPct(report.summary.judged_pass_rate)}`
|
||
: ',无判定型用例')} />
|
||
)}
|
||
|
||
<Card style={{ marginBottom: 16 }}>
|
||
<Descriptions size="small" column={2}>
|
||
<Descriptions.Item label="评测对象">{report.target_name}</Descriptions.Item>
|
||
<Descriptions.Item label="评测场景">
|
||
<Space size={6}>
|
||
{report.scenario_name}
|
||
{report.scenario_version != null && <Tag color="geekblue">v{report.scenario_version}</Tag>}
|
||
</Space>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="触发方式">
|
||
<Tag color={triggerColors[report.triggered_by as keyof typeof triggerColors] ?? 'default'}>
|
||
{triggerLabels[report.triggered_by as keyof typeof triggerLabels] ?? report.triggered_by ?? '手动'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="开始时间">{formatDateTime(report.started_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="完成时间">{report.completed_at ? formatDateTime(report.completed_at) : '-'}</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
|
||
<Card title="用例明细">
|
||
<Collapse items={report.cases.map((c) => ({
|
||
key: c.case_id,
|
||
label: (
|
||
<Space>
|
||
<span style={{ fontWeight: 500 }}>{c.case_id}</span>
|
||
{c.connectivity
|
||
? <Tag color="blue">连通用例</Tag>
|
||
: c.results.every((r) => r.passed)
|
||
? <Tag color="success">全部通过</Tag>
|
||
: <Tag color="error">存在失败</Tag>}
|
||
<Tag>{c.turns.length} 轮对话</Tag>
|
||
</Space>
|
||
),
|
||
children: <CaseDetail c={c} />,
|
||
}))} />
|
||
</Card>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function GoNoGoBanner({ verdict }: { verdict: GoNoGoVerdict }) {
|
||
const meta: Record<string, { type: 'success' | 'error' | 'warning'; label: string }> = {
|
||
go: { type: 'success', label: 'GO — 建议上线' },
|
||
no_go: { type: 'error', label: 'NO-GO — 不建议上线' },
|
||
conditional: { type: 'warning', label: '有条件通过 — 修复后复测' },
|
||
}
|
||
const m = meta[verdict.decision] ?? { type: 'warning' as const, label: verdict.decision }
|
||
|
||
return (
|
||
<Alert
|
||
type={m.type}
|
||
showIcon
|
||
banner
|
||
style={{ marginBottom: 16 }}
|
||
message={
|
||
<Space size={8}>
|
||
<span style={{ fontWeight: 600 }}>上线评估:{m.label}</span>
|
||
<span style={{ color: colors.textMuted, fontWeight: 400, fontSize: 12 }}>{verdict.summary}</span>
|
||
</Space>
|
||
}
|
||
description={verdict.criteria_results.length > 0 && (
|
||
<Space size={[6, 6]} wrap style={{ marginTop: 4 }}>
|
||
{verdict.criteria_results.map((r) => (
|
||
<Tag
|
||
key={r.criterion}
|
||
color={r.passed ? 'success' : 'error'}
|
||
icon={r.passed ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||
style={{ marginRight: 0 }}
|
||
>
|
||
{r.detail || `${r.criterion}: ${r.actual}`}
|
||
</Tag>
|
||
))}
|
||
</Space>
|
||
)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CaseDetail({ c }: { c: CaseReport }) {
|
||
return (
|
||
<div>
|
||
<div style={{ marginBottom: 16 }}>
|
||
{c.turns.map((turn, idx) => (
|
||
<div key={idx} style={{ marginBottom: 12 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 6 }}>
|
||
<div style={{
|
||
background: colors.chatUser, padding: '8px 14px',
|
||
borderRadius: '12px 12px 12px 2px', maxWidth: '70%',
|
||
fontSize: 13, lineHeight: 1.6,
|
||
}}>
|
||
<UserOutlined style={{ marginRight: 6, color: '#1677ff' }} />
|
||
{turn.sent_text}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<div style={{
|
||
background: colors.chatAgent, padding: '8px 14px',
|
||
borderRadius: '12px 12px 2px 12px', maxWidth: '70%',
|
||
fontSize: 13, lineHeight: 1.6,
|
||
}}>
|
||
<RobotOutlined style={{ marginRight: 6, color: '#52c41a' }} />
|
||
{turn.reply_text || '(无回复)'}
|
||
{turn.latency_ms != null && (
|
||
<span style={{ color: '#999', fontSize: 11, marginLeft: 8 }}>{turn.latency_ms}ms</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Table size="small" pagination={false} dataSource={c.results} rowKey={(_, idx) => String(idx)}
|
||
columns={[
|
||
{ title: '规则', dataIndex: 'rule_type', width: 160 },
|
||
{ title: '结果', dataIndex: 'passed', width: 80,
|
||
render: (p: boolean) => p ? <Tag color="success">通过</Tag> : <Tag color="error">失败</Tag> },
|
||
{ title: '评分', dataIndex: 'score', width: 80,
|
||
render: (s: number | null) => s != null ? s.toFixed(2) : '-' },
|
||
{ title: '说明', dataIndex: 'reason' },
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Compare View ─────────────────────────────────────────────────────────
|
||
|
||
function CompareView({ result }: { result: CompareResult | null }) {
|
||
if (!result) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}>
|
||
<Empty description="选择两次评测记录后点击「对比」" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const { run_a, run_b, delta, cases, changed_cases } = result
|
||
const deltaColor = (v: number) => v > 0 ? statusColors.completed : v < 0 ? statusColors.failed : colors.textMuted
|
||
const deltaSign = (v: number) => v > 0 ? `+${v}` : String(v)
|
||
|
||
return (
|
||
<>
|
||
{/* 汇总对比 */}
|
||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||
<Col span={11}>
|
||
<Card title={<span style={{ color: '#1677ff' }}>报告 A — {run_a.run_id.slice(0, 8)}…</span>} size="small">
|
||
<Descriptions size="small" column={1}>
|
||
<Descriptions.Item label="场景">
|
||
{run_a.scenario_name}
|
||
{run_a.scenario_version != null && <Tag color="geekblue" style={{ marginLeft: 6 }}>v{run_a.scenario_version}</Tag>}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="触发方式">
|
||
<Tag color={triggerColors[run_a.triggered_by as keyof typeof triggerColors] ?? 'default'}>
|
||
{triggerLabels[run_a.triggered_by as keyof typeof triggerLabels] ?? run_a.triggered_by ?? '手动'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="时间">{formatDateTime(run_a.started_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="通过率">{fmtPct(run_a.summary.pass_rate)}</Descriptions.Item>
|
||
<Descriptions.Item label="用例">{run_a.summary.passed_cases}/{run_a.summary.total_cases}</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
</Col>
|
||
<Col span={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
|
||
<div style={{ fontSize: 11, color: colors.textMuted }}>变化</div>
|
||
<div style={{ fontWeight: 700, color: deltaColor(delta.pass_rate), fontSize: 16 }}>
|
||
{deltaSign(Math.round(delta.pass_rate * 1000) / 10)}%
|
||
</div>
|
||
<Badge count={changed_cases} color={changed_cases > 0 ? 'orange' : 'green'}
|
||
title={`${changed_cases} 个用例结果变化`} />
|
||
</Col>
|
||
<Col span={11}>
|
||
<Card title={<span style={{ color: '#52c41a' }}>报告 B — {run_b.run_id.slice(0, 8)}…</span>} size="small">
|
||
<Descriptions size="small" column={1}>
|
||
<Descriptions.Item label="场景">
|
||
{run_b.scenario_name}
|
||
{run_b.scenario_version != null && <Tag color="geekblue" style={{ marginLeft: 6 }}>v{run_b.scenario_version}</Tag>}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="触发方式">
|
||
<Tag color={triggerColors[run_b.triggered_by as keyof typeof triggerColors] ?? 'default'}>
|
||
{triggerLabels[run_b.triggered_by as keyof typeof triggerLabels] ?? run_b.triggered_by ?? '手动'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="时间">{formatDateTime(run_b.started_at)}</Descriptions.Item>
|
||
<Descriptions.Item label="通过率">{fmtPct(run_b.summary.pass_rate)}</Descriptions.Item>
|
||
<Descriptions.Item label="用例">{run_b.summary.passed_cases}/{run_b.summary.total_cases}</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 用例对比表 */}
|
||
<Card title={`用例对比(${changed_cases} 个结果变化)`}>
|
||
<Table
|
||
size="small"
|
||
pagination={cases.length > 20 ? { pageSize: 20, showTotal: (t) => `共 ${t} 个用例` } : false}
|
||
dataSource={cases}
|
||
rowKey="case_id"
|
||
rowClassName={(r) => r.changed ? 'run-row' : ''}
|
||
columns={[
|
||
{ title: '用例', dataIndex: 'case_id', width: 180,
|
||
render: (id: string, r) => (
|
||
<Space>
|
||
{r.changed && <Badge dot color="orange" />}
|
||
<span style={{ fontWeight: r.changed ? 600 : 400 }}>{id}</span>
|
||
{r.connectivity && <Tag color="blue">连通</Tag>}
|
||
</Space>
|
||
),
|
||
},
|
||
{ title: '报告 A', dataIndex: 'run_a_passed', width: 100,
|
||
render: (p: boolean | null) =>
|
||
p === null ? <Tag>无</Tag>
|
||
: p ? <Tag color="success">通过</Tag>
|
||
: <Tag color="error">失败</Tag>,
|
||
},
|
||
{ title: '报告 B', dataIndex: 'run_b_passed', width: 100,
|
||
render: (p: boolean | null) =>
|
||
p === null ? <Tag>无</Tag>
|
||
: p ? <Tag color="success">通过</Tag>
|
||
: <Tag color="error">失败</Tag>,
|
||
},
|
||
{ title: '变化', width: 80,
|
||
render: (_: any, r) => {
|
||
if (!r.changed) return <span style={{ color: colors.textMuted }}>—</span>
|
||
if (r.run_b_passed && !r.run_a_passed) return <Tag color="success">改善 ↑</Tag>
|
||
if (!r.run_b_passed && r.run_a_passed) return <Tag color="error">退步 ↓</Tag>
|
||
return <Tag color="orange">变化</Tag>
|
||
},
|
||
},
|
||
]}
|
||
expandable={{
|
||
expandedRowRender: (r) => (
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}>报告 A 规则</div>
|
||
{r.run_a_results.map((res, i) => (
|
||
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
|
||
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
|
||
</div>
|
||
))}
|
||
</Col>
|
||
<Col span={12}>
|
||
<div style={{ fontSize: 12, color: colors.textMuted, marginBottom: 4 }}>报告 B 规则</div>
|
||
{r.run_b_results.map((res, i) => (
|
||
<div key={i} style={{ fontSize: 12, marginBottom: 2 }}>
|
||
{res.passed ? '✅' : '❌'} {res.rule_type}: {res.reason}
|
||
</div>
|
||
))}
|
||
</Col>
|
||
</Row>
|
||
),
|
||
rowExpandable: (r) => r.run_a_results.length > 0 || r.run_b_results.length > 0,
|
||
}}
|
||
/>
|
||
</Card>
|
||
</>
|
||
)
|
||
}
|