AgentEvalTool/frontend/web/src/pages/Reports.tsx
sinohqb 3705945a7d test: 完整测试覆盖补全(+163 用例)
架构重构(候选 1-6):
- storage/repository.py 按域拆分为包(target/scenario/run/campaign/result)
- storage/db.py 按域拆分为包(eval/campaign/file/model_config/intelligent_eval)
- intelligent_eval/lifecycle.py 按状态机阶段拆分为包
- services/runs.py 编排逻辑下沉
- Campaigns.tsx 拆分为 campaigns/ 子组件

测试补全(候选 7):
前端(+125 用例,107→232):
- utils/ 纯函数:date/campaignTime/ruleLabels/fileTree/fileFormat/colors
- stores/tabStore 状态管理
- 核心组件:FormDrawer/PageWrapper/ChatBubble/GeneratedMessages/SectionHeader/StatCard/TurnList
- 业务组件:CaseBlock/CaseDetail/RuleOverview/WindowTimeline/RunList/TabBar/CampaignRunTimeline
- 文件管理:FileCategoryTree/FileTable
- hooks:sessionReducer/useFiles/useRunSession

后端(+38 用例,916→954):
- targets API CRUD + 404 路径
- WebSocket 连接管理器
- proxy 头部重写(CSP/X-Frame-Options)
- target 仓储 update 方法
- app 健康检查 + SPA 404
- scenarios 模板端点 + 404
- files API 边缘分支(404 场景 + 500 兜底)
- files service update_category
- 智能评估状态机迁移测试

门禁状态:
- 前端:tsc 干净 + 232 passed
- 后端:954 passed + ruff 全绿
2026-08-24 15:56:09 +08:00

600 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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
}
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 (
<>
<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 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>
</>
)
}