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
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|