AgentEvalTool/frontend/web/src/components/PeriodComparisonSection.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

287 lines
11 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 { useState, type CSSProperties } from 'react'
import { Alert, Button, Select, Space, Spin, Table, Tag, Tooltip } from 'antd'
import {
type CampaignComparison, type CampaignListItem, type MetricDeltaPair,
type MetricDiff, type ModelConfig,
} from '../api'
import { shortDateTime } from '../utils/date'
import { colors, statusColors } from '../tokens'
const TREND_META: Record<string, { label: string; color: string }> = {
improving: { label: '改善', color: 'green' },
stable: { label: '平稳', color: 'blue' },
regressing: { label: '退化', color: 'red' },
}
const EVOLUTION_META: Record<string, { label: string; color: string }> = {
new: { label: '新增', color: 'red' },
persisting: { label: '持续', color: 'orange' },
resolved: { label: '消解', color: 'green' },
}
const TRACKING_META: Record<string, { label: string; color: string }> = {
addressed: { label: '已落实', color: 'green' },
partial: { label: '部分落实', color: 'orange' },
unaddressed: { label: '未落实', color: 'default' },
new: { label: '新增', color: 'blue' },
}
type MetricKey = 'pass_rate' | 'availability' | 'avg_latency_ms'
const fmtPct = (v: number) => `${(v * 100).toFixed(1)}%`
const signed = (d: number, body: string) => `${d > 0 ? '+' : ''}${body}`
/** Single metric meta table: label, formatting and which direction is good. */
const METRICS: {
key: MetricKey
label: string
goodDirection: 'up' | 'down'
fmt: (v: number) => string
fmtDelta: (d: number) => string
}[] = [
{ key: 'pass_rate', label: '通过率', goodDirection: 'up', fmt: fmtPct, fmtDelta: (d) => signed(d, `${(d * 100).toFixed(1)}pp`) },
{ key: 'availability', label: '可用性', goodDirection: 'up', fmt: fmtPct, fmtDelta: (d) => signed(d, `${(d * 100).toFixed(1)}pp`) },
{ key: 'avg_latency_ms', label: '平均时延', goodDirection: 'down', fmt: (v) => `${Math.round(v)}ms`, fmtDelta: (d) => signed(d, `${Math.round(d * 10) / 10}ms`) },
]
const boxStyle: CSSProperties = {
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 14px', marginBottom: 16,
}
interface Props {
comparison: CampaignComparison | null
/** 活动已终态(按钮门控之一) */
terminal: boolean
/** 当前活动智能分析已 completed对比前提 */
analysisCompleted: boolean
targetId: string
currentCampaignId: string
campaigns: CampaignListItem[]
scenarioNames: Record<string, string>
modelConfigs: ModelConfig[]
onGenerate: (baselineCampaignId?: string) => Promise<void>
}
export default function PeriodComparisonSection({
comparison, terminal, analysisCompleted, targetId, currentCampaignId,
campaigns, scenarioNames, modelConfigs, onGenerate,
}: Props) {
const [busy, setBusy] = useState(false)
const [baselineChoice, setBaselineChoice] = useState<string | undefined>(undefined)
const generate = async (baselineCampaignId?: string) => {
setBusy(true)
try {
await onGenerate(baselineCampaignId)
} finally {
setBusy(false)
}
}
const baselineCandidates = campaigns
.filter((c) => c.target_id === targetId
&& c.id !== currentCampaignId
&& c.completed_at != null
&& !(c.status === 'planned' || c.status === 'running'))
.slice()
.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''))
const button = (label: string, baselineCampaignId?: string, requireBaseline = false) => (
<Tooltip title={
!terminal ? '活动完成后可生成'
: !analysisCompleted ? '请先生成智能分析'
: requireBaseline && !baselineCampaignId ? '请先选择基线活动'
: undefined
}>
<Button
size="small" type="primary" ghost
loading={busy}
disabled={!terminal || !analysisCompleted || (requireBaseline && !baselineCampaignId)}
onClick={() => void generate(baselineCampaignId)}
>
{label}
</Button>
</Tooltip>
)
const metricDiffTable = (diff: MetricDiff) => {
const rows = [
{ key: 'overall', name: '整窗(总体)', ...diff.overall },
...diff.scenarios.map((s) => ({
key: s.scenario_id,
name: s.scenario_name || scenarioNames[s.scenario_id] || s.scenario_id.slice(0, 8),
pass_rate: s.pass_rate, availability: s.availability, avg_latency_ms: s.avg_latency_ms,
})),
]
const renderPair = (metric: typeof METRICS[number]) => (_: unknown, r: typeof rows[number]) => {
const pair: MetricDeltaPair = r[metric.key]
const good = metric.goodDirection === 'down' ? (pair.delta ?? 0) < 0 : (pair.delta ?? 0) > 0
const deltaColor = pair.delta == null || pair.delta === 0
? colors.textMuted
: good ? statusColors.completed : statusColors.failed
return (
<Space size={4}>
<span style={{ color: colors.textMuted }}>
{pair.baseline == null ? '—' : metric.fmt(pair.baseline)}
</span>
<span style={{ color: colors.textMuted }}></span>
<span>{pair.current == null ? '—' : metric.fmt(pair.current)}</span>
<span style={{ color: deltaColor, fontWeight: 500 }}>
{pair.delta == null ? '—' : metric.fmtDelta(pair.delta)}
</span>
</Space>
)
}
return (
<Table
rowKey="key"
size="small"
dataSource={rows}
pagination={false}
columns={[
{ title: '维度', dataIndex: 'name', key: 'name' },
...METRICS.map((m) => ({ title: m.label, key: m.key, render: renderPair(m) })),
]}
style={{ marginBottom: 12 }}
/>
)
}
const status = comparison?.status ?? 'none'
if (status === 'generating') {
return (
<div style={boxStyle}>
<Spin size="small" />
<span style={{ marginLeft: 10, color: colors.textSecondary }}>
</span>
</div>
)
}
if (status === 'failed') {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="error" showIcon message="周期对比生成失败"
description={comparison?.comparison?.error || '未知错误'}
action={button('重试', comparison?.comparison?.baseline_campaign_id)}
/>
</div>
)
}
const entry = comparison?.comparison ?? null
if (!entry || !entry.result) {
const autoBaseline = comparison?.auto_baseline ?? null
return (
<div style={{ ...boxStyle, background: colors.bgSubtle }}>
<div style={{ color: colors.textSecondary, marginBottom: 8 }}>
{autoBaseline
? <>线{autoBaseline.name}{shortDateTime(autoBaseline.completed_at)}</>
: '未找到自动基线(需同计划、正式线、更早完成且已有智能分析的历史活动)。可手动选择基线。'}
</div>
<Space size={12} wrap>
{autoBaseline
? button('生成对比')
: (
<>
<Select
size="small"
style={{ minWidth: 260 }}
placeholder="选择基线活动"
value={baselineChoice}
onChange={(v) => setBaselineChoice(v)}
options={baselineCandidates.map((c) => ({
label: `${c.name}(完成于 ${shortDateTime(c.completed_at)}`,
value: c.id,
}))}
notFoundContent="没有可选的历史活动"
/>
{button('生成对比', baselineChoice, true)}
</>
)}
{!analysisCompleted && (
<span style={{ fontSize: 12, color: colors.warning }}></span>
)}
</Space>
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
</div>
)
}
const result = entry.result
const baseline = entry.baseline
const trendMeta = TREND_META[result.trend] ?? TREND_META.stable
const modelName = entry.model_config_id
? modelConfigs.find((m) => m.id === entry.model_config_id)?.name ?? entry.model_config_id.slice(0, 8)
: null
return (
<div style={boxStyle}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
fontSize: 12, color: colors.textSecondary, marginBottom: 10,
}}>
<span>
线{baseline?.name ?? entry.baseline_campaign_id.slice(0, 8)}
{baseline?.completed_at && <> {shortDateTime(baseline.completed_at)}</>}
{' · '} {modelName ?? '未知'}
{entry.updated_at && <> · {shortDateTime(entry.updated_at)}</>}
</span>
{button('重新生成', entry.baseline_campaign_id)}
</div>
<div style={{
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
padding: '10px 14px', marginBottom: 12,
}}>
<Space size={8} align="start">
<Tag color={trendMeta.color} style={{ margin: 0 }}>{trendMeta.label}</Tag>
<span><b></b>{result.summary}</span>
</Space>
</div>
{comparison?.metric_diff && metricDiffTable(comparison.metric_diff)}
{result.problem_evolution.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{result.problem_evolution.map((p, i) => {
const meta = EVOLUTION_META[p.status] ?? EVOLUTION_META.persisting
return (
<div key={i} style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 12px' }}>
<Space size={8} wrap>
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
<b>{p.title}</b>
{p.scenario_ids.map((sid) => (
<Tag key={sid} style={{ margin: 0 }}>{scenarioNames[sid] ?? sid.slice(0, 8)}</Tag>
))}
</Space>
<div style={{ color: colors.textSecondary, marginTop: 6 }}>{p.detail}</div>
</div>
)
})}
</Space>
</div>
)}
{result.suggestion_tracking.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{result.suggestion_tracking.map((s, i) => {
const meta = TRACKING_META[s.status] ?? TRACKING_META.unaddressed
return (
<div key={i}>
<Space size={8} wrap>
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
<span>{s.text}</span>
</Space>
{s.note && (
<div style={{ color: colors.textSecondary, fontSize: 12, margin: '2px 0 0 22px' }}>{s.note}</div>
)}
</div>
)
})}
</Space>
</div>
)}
</div>
)
}