AgentEvalTool/frontend/web/src/components/intelligent_eval/EvalReport.tsx
sinohqb fa5e3b8d5d refactor(ui): consolidate state maps + colors to tokens (audit P1)
UI/UX 盘点 P1 一致性修复:
- 收敛重复状态映射:ExplorationSection 复用 SESSION_STATUS、TaskQueueMonitor
  复用 EVAL_STATUS(消除 blue/green 与 processing/success 颜色漂移)、
  Campaigns 复用 SEVERITY_META(均来自 intelligent_eval/status.ts)
- 硬编码颜色走 token:Reports 通过率用 passRateColor、通过/失败/delta 用
  statusColors;IntelligentEvals/TaskQueueMonitor/EvalReport/PeriodComparison
  的 #52c41a/#ff4d4f 用 statusColors.completed/failed;RunList/RuleOverview
  的 #faad14 用 colors.warning;Home 的 #1677ff 用 colors.primary
tsc 0 错误 vitest 19 passed
2026-08-17 23:42:13 +08:00

373 lines
14 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, useState } from 'react'
import {
Button, Card, Col, Collapse, Empty, Row, Space, Spin, Tag, message,
} from 'antd'
import {
ArrowLeftOutlined, BulbOutlined, DownloadOutlined, MessageOutlined,
StarOutlined, WarningOutlined,
} from '@ant-design/icons'
import { Bar } from '@ant-design/charts'
import ChatBubble from '../ChatBubble'
import {
intelligentEvalsApi,
type IntelligentEval,
type IntelligentEvalMessage,
type IntelligentEvalReport,
type IntelligentEvalSession,
type ReportEvidence,
} from '../../api'
import { colors, statusColors } from '../../tokens'
import { shortDateTime } from '../../utils/date'
import { SESSION_STATUS, severityOf } from './status'
const sectionCard: React.CSSProperties = { marginBottom: 16 }
function personaLabel(persona: Record<string, unknown>): string {
if (typeof persona.name === 'string' && persona.name) return persona.name
if (typeof persona.background === 'string' && persona.background) return persona.background
return persona.id != null ? String(persona.id) : '虚拟用户'
}
function EvidenceBlock({ evidence }: { evidence: ReportEvidence }) {
return (
<div style={{
background: colors.bgSubtle, borderLeft: `3px solid ${colors.border}`,
padding: '6px 10px', borderRadius: 4, fontSize: 12, lineHeight: 1.7,
}}
>
{evidence.session_id && (
<div style={{ color: colors.textMuted }}>
{evidence.session_id.slice(0, 8)}
{evidence.turn_index != null ? ` · 第 ${evidence.turn_index + 1}` : ''}
</div>
)}
{evidence.user_said && <div>{evidence.user_said}</div>}
{evidence.assistant_replied && <div>{evidence.assistant_replied}</div>}
</div>
)
}
function VerdictView({ verdict }: { verdict: Record<string, unknown> }) {
const entries = Object.entries(verdict)
if (entries.length === 0) return null
return (
<div style={{
marginTop: 8, border: `1px solid ${colors.border}`, borderRadius: 8,
background: colors.bgSubtle, padding: '8px 12px', fontSize: 12, lineHeight: 1.8,
}}
>
<div style={{ fontWeight: 500, marginBottom: 4 }}></div>
{entries.map(([k, v]) => (
<div key={k} style={{ color: colors.textSecondary }}>
<b style={{ color: colors.text }}>{k}</b>
{typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)}
</div>
))}
</div>
)
}
function SessionMessages({ evalId, sessionId, verdict }: {
evalId: string
sessionId: string
verdict: Record<string, unknown> | null
}) {
const [messages, setMessages] = useState<IntelligentEvalMessage[] | null>(null)
useEffect(() => {
let cancelled = false
intelligentEvalsApi.listMessages(evalId, sessionId)
.then((res) => { if (!cancelled) setMessages(res.data.messages) })
.catch(() => undefined) // 拦截器已弹错;收起再展开可重试
return () => { cancelled = true }
}, [evalId, sessionId])
return (
<div>
{!messages && <Spin size="small" />}
{messages && messages.length === 0 && (
<Empty description="暂无对话记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{messages?.map((m) => (
<ChatBubble
key={m.id}
role={m.role === 'user' ? 'user' : 'agent'}
mirrored
content={m.content}
meta={m.role !== 'user' && m.latency_ms != null ? (
<span style={{ color: colors.textMuted, fontSize: 11 }}>{m.latency_ms}ms</span>
) : undefined}
/>
))}
{verdict && <VerdictView verdict={verdict} />}
</div>
)
}
interface EvalReportProps {
ev: IntelligentEval
/** 独立页内作为子视图 tab 使用时可不传tab 切换代替返回)。 */
onBack?: () => void
}
export default function EvalReport({ ev, onBack }: EvalReportProps) {
const [exporting, setExporting] = useState(false)
const report: IntelligentEvalReport | null = ev.report
const reportLoading = false
const sessions: IntelligentEvalSession[] = ev.sessions ?? []
const sessionsLoading = false
const exportMarkdown = async () => {
setExporting(true)
try {
await intelligentEvalsApi.downloadReportMarkdown(ev.id)
message.success('已导出 Markdown 报告')
} finally {
setExporting(false)
}
}
const findings = [...(report?.findings ?? [])]
.sort((a, b) => severityOf(a.severity).order - severityOf(b.severity).order)
// report.scores 是嵌套结构:{ overall: number, dimensions: { 维度: 分 } }
// 只把 dimensions 的值当作数字维度overall 单独取,避免 Number(对象) 产生 NaN。
const rawScores = report?.scores ?? {}
const dimScores = Object.entries(rawScores.dimensions ?? {}).filter(
([, v]) => typeof v === 'number',
)
const rawOverall = rawScores.overall
const overall = typeof rawOverall === 'number'
? rawOverall.toFixed(1)
: dimScores.length > 0
? (dimScores.reduce((sum, [, v]) => sum + Number(v), 0) / dimScores.length).toFixed(1)
: null
const overallNum = overall != null ? Number(overall) : null
const overallColor = overallNum == null ? colors.textMuted
: overallNum >= 0.8 ? statusColors.completed
: overallNum >= 0.6 ? colors.warning : statusColors.failed
const sevCounts = findings.reduce((acc, f) => {
acc[f.severity] = (acc[f.severity] ?? 0) + 1
return acc
}, {} as Record<string, number>)
const scoreData = dimScores.map(([dimension, score]) => ({ dimension, score: Number(score) }))
const scoreConfig = {
data: scoreData,
xField: 'dimension',
yField: 'score',
coordinate: { transform: [{ type: 'transpose' }] },
height: Math.max(160, scoreData.length * 44),
color: colors.primary,
}
const summaryCard: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10,
border: `1px solid ${colors.border}`,
borderRadius: 10, padding: '14px 16px', background: colors.bgContainer,
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
height: '100%',
}
const summaryIcon = (color: string): React.CSSProperties => ({
width: 42, height: 42, borderRadius: 10, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `${color}1a`, color, fontSize: 20,
})
return (
<div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
{onBack && <Button icon={<ArrowLeftOutlined />} onClick={onBack}></Button>}
<span style={{ fontSize: 16, fontWeight: 600, color: colors.text }}>
{ev.name} ·
</span>
<div style={{ flex: 1 }} />
{report && (
<Button icon={<DownloadOutlined />} loading={exporting} onClick={exportMarkdown}>
Markdown
</Button>
)}
</div>
{reportLoading && <Spin />}
{!reportLoading && !report && (
<Empty description="暂无报告" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{report && (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={12} md={6}>
<div style={summaryCard}>
<div style={summaryIcon(overallColor)}><StarOutlined /></div>
<div>
<div style={{ fontSize: 12, color: colors.textMuted }}></div>
<div style={{ fontSize: 26, fontWeight: 700, color: overallColor, lineHeight: 1.2 }}>
{overall ?? '—'}
</div>
</div>
</div>
</Col>
<Col xs={12} md={6}>
<div style={summaryCard}>
<div style={summaryIcon(statusColors.failed)}><WarningOutlined /></div>
<div>
<div style={{ fontSize: 12, color: colors.textMuted }}></div>
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
{findings.length}
</div>
<div style={{ fontSize: 12, color: colors.textMuted }}>
{sevCounts.high ?? 0} · {sevCounts.medium ?? 0} · {sevCounts.low ?? 0}
</div>
</div>
</div>
</Col>
<Col xs={12} md={6}>
<div style={summaryCard}>
<div style={summaryIcon(colors.warning)}><BulbOutlined /></div>
<div>
<div style={{ fontSize: 12, color: colors.textMuted }}></div>
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
{report.highlights.length}
</div>
</div>
</div>
</Col>
<Col xs={12} md={6}>
<div style={summaryCard}>
<div style={summaryIcon(colors.primary)}><MessageOutlined /></div>
<div>
<div style={{ fontSize: 12, color: colors.textMuted }}></div>
<div style={{ fontSize: 26, fontWeight: 700, color: colors.text, lineHeight: 1.2 }}>
{ev.session_count}
</div>
</div>
</div>
</Col>
</Row>
{scoreData.length > 0 && (
<Card size="small" title="维度得分" style={sectionCard}>
<Bar {...scoreConfig} />
</Card>
)}
<Card size="small" title="总结" style={sectionCard}>
<div style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.8 }}>
{report.summary}
</div>
</Card>
<Card size="small" title={`问题发现(${findings.length}`} style={sectionCard}>
{findings.length === 0 && (
<div style={{ fontSize: 13, color: colors.textSecondary }}></div>
)}
<Collapse
size="small"
items={findings.map((f, i) => {
const sev = severityOf(f.severity)
return {
key: i,
label: (
<Space size={8} wrap>
<Tag color={sev.color} style={{ margin: 0 }}>{sev.label}</Tag>
<Tag style={{ margin: 0 }}>{f.dimension}</Tag>
<span style={{ fontWeight: 500 }}>{f.issue}</span>
</Space>
),
children: (
<div>
{f.evidence.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 6 }}>
{f.evidence.length}
</div>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{f.evidence.map((e, j) => <EvidenceBlock key={j} evidence={e} />)}
</Space>
</div>
)}
{f.suggestion && (
<div style={{ fontSize: 13, marginBottom: 4 }}>
<b></b>{f.suggestion}
</div>
)}
{f.related_sop && (
<div style={{ fontSize: 12, color: colors.textMuted }}>
SOP{f.related_sop}
</div>
)}
</div>
),
}
})}
/>
</Card>
{report.highlights.length > 0 && (
<Card size="small" title="亮点" style={sectionCard}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{report.highlights.map((h, i) => (
<div key={i} style={{ fontSize: 13 }}>
{h.dimension && <Tag color="green" style={{ margin: 0 }}>{h.dimension}</Tag>}
{h.dimension ? ' ' : ''}{h.description}
</div>
))}
</Space>
</Card>
)}
{report.priority_recommendations.length > 0 && (
<Card size="small" title="优先改进建议" style={sectionCard}>
{report.priority_recommendations.map((r, i) => (
<div key={i} style={{ fontSize: 13, marginBottom: 4 }}>
<Tag color="blue" style={{ margin: 0 }}>{i + 1}</Tag> {r}
</div>
))}
</Card>
)}
</>
)}
<Card size="small" title="会话记录" style={sectionCard}>
{sessionsLoading && <Spin size="small" />}
{!sessionsLoading && sessions && sessions.length === 0 && (
<Empty description="暂无会话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
{!sessionsLoading && sessions && sessions.length > 0 && (
<Collapse
size="small"
items={sessions.map((s) => {
const meta = SESSION_STATUS[s.status] ?? SESSION_STATUS.running
return {
key: s.id,
label: (
<Space size={8} wrap>
<span style={{ fontWeight: 500 }}>{personaLabel(s.persona)}</span>
<Tag color={meta.color}>{meta.label}</Tag>
<Tag>{s.turn_count} </Tag>
{s.dimension && <Tag>{s.dimension}</Tag>}
{s.created_at && (
<span style={{ fontSize: 12, color: colors.textMuted }}>
{shortDateTime(s.created_at)}
</span>
)}
</Space>
),
children: (
<div>
<div style={{ fontSize: 12, color: colors.textSecondary, marginBottom: 8 }}>
{s.goal}
</div>
<SessionMessages evalId={ev.id} sessionId={s.id} verdict={s.verdict} />
</div>
),
}
})}
/>
)}
</Card>
</div>
)
}