refactor(comparison): 评审修复 — 共享 gateway_chat_client、指标元表、对比区块组件化

- analysis/comparison 重复的 _gateway_chat_client 提取为共享 gateway_chat_client
- Campaigns.tsx 周期对比区块抽为 PeriodComparisonSection 组件,指标格式化
  收敛为单一 METRICS 元表(消除三处 metric 分支级联)
- 基线下拉排除无 completed_at 的终态活动(选中必 400)
- spec/issue 03 追认 delta 按好坏着色口径与 GET 生效基线合并口径
This commit is contained in:
sinohqb 2026-08-03 14:26:20 +08:00
parent 1e55a21649
commit dd3b9a5e91
6 changed files with 312 additions and 276 deletions

View File

@ -1,6 +1,6 @@
# 03 — 报告抽屉「周期对比」区块 # 03 — 报告抽屉「周期对比」区块
**What to build:** 报告抽屉在「智能分析」之下新增「周期对比」区块。状态行生成中spinner + 5s 轮询)、失败(错误原因 + 重试、未生成「生成对比」按钮无自动基线时显示基线选择器——同对象历史终态活动下拉选定后生成。已生成依次渲染趋势徽章improving 绿 / stable 蓝 / regressing 红)+ 总结 → 指标变化表(整窗行 + 分场景行delta 正绿负红、None 显示 —)→ 问题演化列表(新增红 / 持续橙 / 消解绿 Tag + 标题 + 说明 + 场景 Tag→ 建议追踪(已落实绿 / 部分橙 / 未落实灰 / 新增蓝)。展示基线活动名与完成时间、所用分析模型名与生成时间。生成按钮仅终态活动可用。 **What to build:** 报告抽屉在「智能分析」之下新增「周期对比」区块。状态行生成中spinner + 5s 轮询)、失败(错误原因 + 重试、未生成「生成对比」按钮无自动基线时显示基线选择器——同对象历史终态活动下拉选定后生成。已生成依次渲染趋势徽章improving 绿 / stable 蓝 / regressing 红)+ 总结 → 指标变化表(整窗行 + 分场景行delta 按好坏着色——比率升为绿、时延降为绿,反之红、None 显示 —)→ 问题演化列表(新增红 / 持续橙 / 消解绿 Tag + 标题 + 说明 + 场景 Tag→ 建议追踪(已落实绿 / 部分橙 / 未落实灰 / 新增蓝)。展示基线活动名与完成时间、所用分析模型名与生成时间。生成按钮仅终态活动可用。
**Blocked by:** 02API 与结果结构) **Blocked by:** 02API 与结果结构)
@ -8,7 +8,7 @@
- [x] `api.ts`:对比类型 + `getComparison` / `generateComparison(id, baselineId?)` - [x] `api.ts`:对比类型 + `getComparison` / `generateComparison(id, baselineId?)`
- [x] 区块按状态渲染(生成中轮询复用 `usePolling` - [x] 区块按状态渲染(生成中轮询复用 `usePolling`
- [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(正绿负红/ 问题演化 Tag / 建议追踪状态 - [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(按好坏着色/ 问题演化 Tag / 建议追踪状态
- [x] 无自动基线时的基线选择器(同对象终态活动下拉) - [x] 无自动基线时的基线选择器(同对象终态活动下拉)
- [x] 基线活动名 + 完成时间 + 模型名 + 生成时间展示;按钮终态门控 - [x] 基线活动名 + 完成时间 + 模型名 + 生成时间展示;按钮终态门控
- [x] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证 - [x] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证

View File

@ -57,14 +57,14 @@
### API ### API
- `GET /api/campaigns/{id}/comparison`:无行 → 空态 `{"status": "none"}`;有行 → 机械 diff现算+ 叙述行合并返回;附 `baseline_campaign_id` 与自动配对结果,前端据此渲染或引导选基线 - `GET /api/campaigns/{id}/comparison`:无行 → 空态 `{"status": "none"}`;有行 → 机械 diff现算+ 叙述行合并返回;附 `baseline_campaign_id` 与自动配对结果,前端据此渲染或引导选基线。`metric_diff` 跟随生效基线:有对比行按行内基线算,否则按自动基线算
- `POST /api/campaigns/{id}/comparison`body 可选 `{"baseline_campaign_id": "..."}`;非终态活动 400当前活动无 completed 分析 400先生成分析无模型 400 引导;成功 → 后台任务 + `{"status": "generating"}` - `POST /api/campaigns/{id}/comparison`body 可选 `{"baseline_campaign_id": "..."}`;非终态活动 400当前活动无 completed 分析 400先生成分析无模型 400 引导;成功 → 后台任务 + `{"status": "generating"}`
- 两期都有 completed 分析是生成前提(基线无分析 → 400 提示先生成基线分析) - 两期都有 completed 分析是生成前提(基线无分析 → 400 提示先生成基线分析)
### 前端(报告抽屉「周期对比」区块) ### 前端(报告抽屉「周期对比」区块)
- 位置:「智能分析」区块之下,复用区块状态行范式(生成中 5s 轮询 / 失败重试 / 未生成按钮) - 位置:「智能分析」区块之下,复用区块状态行范式(生成中 5s 轮询 / 失败重试 / 未生成按钮)
- 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta正绿负红)→ 问题演化(新增红/持续橙/消解绿 Tag→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝) - 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta按好坏着色——比率升为绿、时延降为绿,反之红)→ 问题演化(新增红/持续橙/消解绿 Tag→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝)
- 无自动基线:基线选择器(同对象历史终态活动下拉)+ 生成按钮 - 无自动基线:基线选择器(同对象历史终态活动下拉)+ 生成按钮
- 展示基线活动名与完成时间、所用分析模型名与生成时间 - 展示基线活动名与完成时间、所用分析模型名与生成时间

View File

@ -262,7 +262,8 @@ def _maybe_enqueue_period_comparison(campaign: Campaign, session: Session) -> No
_logger.warning("活动 %s 自动周期对比跳过: %s", campaign.id, exc) _logger.warning("活动 %s 自动周期对比跳过: %s", campaign.id, exc)
def _gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient: def gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient:
"""Shared ChatClient factory for analysis/comparison background executors."""
gateway = ModelGateway(timeout=180.0) gateway = ModelGateway(timeout=180.0)
async def _chat(messages: list[dict[str, str]]) -> str: async def _chat(messages: list[dict[str, str]]) -> str:
@ -299,7 +300,7 @@ async def execute_campaign_analysis(
model_config_id=runtime.id, triggered_by=triggered_by, model_config_id=runtime.id, triggered_by=triggered_by,
) )
try: try:
client = chat_client or _gateway_chat_client(runtime) client = chat_client or gateway_chat_client(runtime)
runs = RunRepository(session).list_by_campaign(campaign_id) runs = RunRepository(session).list_by_campaign(campaign_id)
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()} scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
report = generate_campaign_report(campaign, runs, scenario_names=scenario_names) report = generate_campaign_report(campaign, runs, scenario_names=scenario_names)

View File

@ -14,11 +14,9 @@ from typing import Any, Optional
from sqlmodel import Session from sqlmodel import Session
from agenteval.evaluation.analysis import ChatClient, resolve_analysis_model from agenteval.evaluation.analysis import ChatClient, gateway_chat_client, resolve_analysis_model
from agenteval.evaluation.report import generate_campaign_report from agenteval.evaluation.report import generate_campaign_report
from agenteval.model_gateway import ModelGateway
from agenteval.models import Campaign, EvalRun from agenteval.models import Campaign, EvalRun
from agenteval.services.model_configs import ModelRuntimeConfig
from agenteval.storage.db import get_session, iso_utc, utc_now from agenteval.storage.db import get_session, iso_utc, utc_now
from agenteval.storage.repository import ( from agenteval.storage.repository import (
CampaignAnalysisRepository, CampaignAnalysisRepository,
@ -277,15 +275,6 @@ async def narrate_period_comparison(
} }
def _gateway_chat_client(runtime: ModelRuntimeConfig) -> ChatClient:
gateway = ModelGateway(timeout=180.0)
async def _chat(messages: list[dict[str, str]]) -> str:
return await gateway.chat(runtime, messages, temperature=0.2)
return _chat
async def execute_campaign_comparison( async def execute_campaign_comparison(
campaign_id: str, campaign_id: str,
*, *,
@ -378,7 +367,7 @@ async def execute_campaign_comparison(
current_analysis=current_analysis.get_result(), current_analysis=current_analysis.get_result(),
metric_diff=diff, metric_diff=diff,
valid_scenario_ids=valid_scenario_ids, valid_scenario_ids=valid_scenario_ids,
chat_client=chat_client or _gateway_chat_client(runtime), chat_client=chat_client or gateway_chat_client(runtime),
) )
except Exception as exc: except Exception as exc:
_logger.warning("活动 %s 周期对比失败: %s", campaign_id, exc) _logger.warning("活动 %s 周期对比失败: %s", campaign_id, exc)

View File

@ -0,0 +1,286 @@
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 } 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 ? '#52c41a' : '#ff4d4f'
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>
)
}

View File

@ -17,13 +17,14 @@ import {
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi, campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis, type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
type CampaignComparison, type MetricDiff, type CampaignComparison,
} from '../api' } from '../api'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime, toDate } from '../utils/date' import { shortDateTime, toDate } from '../utils/date'
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime' import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline' import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
import CampaignRunTimeline from '../components/CampaignRunTimeline' import CampaignRunTimeline from '../components/CampaignRunTimeline'
import PeriodComparisonSection from '../components/PeriodComparisonSection'
import { useResource } from '../hooks/useResource' import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling' import { usePolling } from '../hooks/usePolling'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
@ -43,25 +44,6 @@ const SEVERITY_META: Record<string, { label: string; color: string }> = {
low: { label: '低', color: 'blue' }, low: { label: '低', color: 'blue' },
} }
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' },
}
const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 })) const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
const UNIT_OPTIONS = [ const UNIT_OPTIONS = [
@ -146,8 +128,6 @@ export default function CampaignsPage() {
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null) const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
const [analysisBusy, setAnalysisBusy] = useState(false) const [analysisBusy, setAnalysisBusy] = useState(false)
const [comparison, setComparison] = useState<CampaignComparison | null>(null) const [comparison, setComparison] = useState<CampaignComparison | null>(null)
const [comparisonBusy, setComparisonBusy] = useState(false)
const [baselineChoice, setBaselineChoice] = useState<string | undefined>(undefined)
const [expandedIds, setExpandedIds] = useState<string[]>([]) const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({}) const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
@ -257,7 +237,6 @@ export default function CampaignsPage() {
setReportTimeline([]) setReportTimeline([])
setAnalysis(null) setAnalysis(null)
setComparison(null) setComparison(null)
setBaselineChoice(undefined)
} }
try { try {
const [rep, runs, tl, ana, cmp] = await Promise.all([ const [rep, runs, tl, ana, cmp] = await Promise.all([
@ -317,14 +296,9 @@ export default function CampaignsPage() {
const generateComparison = async (baselineCampaignId?: string) => { const generateComparison = async (baselineCampaignId?: string) => {
if (!reportId) return if (!reportId) return
setComparisonBusy(true) await campaignsApi.generateComparison(reportId, baselineCampaignId)
try { const res = await campaignsApi.getComparison(reportId)
await campaignsApi.generateComparison(reportId, baselineCampaignId) setComparison(res.data)
const res = await campaignsApi.getComparison(reportId)
setComparison(res.data)
} finally {
setComparisonBusy(false)
}
} }
// 对比生成中同样轮询,直到 completed/failed。 // 对比生成中同样轮询,直到 completed/failed。
@ -685,231 +659,6 @@ export default function CampaignsPage() {
) )
} }
// ── 周期对比区块 ─────────────────────────────────────────────────────
const comparisonReady = analysis?.status === 'completed'
const comparisonModelName = comparison?.comparison?.model_config_id
? (data?.modelConfigs ?? []).find((m) => m.id === comparison.comparison?.model_config_id)?.name
?? comparison.comparison.model_config_id.slice(0, 8)
: null
const baselineCandidates = report
? campaigns
.filter((c) => c.target_id === report.target_id
&& c.id !== report.campaign_id
&& !isActiveStatus(c.status))
.slice()
.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''))
: []
const comparisonButton = (label: string, baselineCampaignId?: string, requireBaseline = false) => (
<Tooltip title={
!analysisTerminal ? '活动完成后可生成'
: !comparisonReady ? '请先生成智能分析'
: requireBaseline && !baselineCampaignId ? '请先选择基线活动'
: undefined
}>
<Button
size="small" type="primary" ghost
loading={comparisonBusy}
disabled={!analysisTerminal || !comparisonReady || (requireBaseline && !baselineCampaignId)}
onClick={() => void generateComparison(baselineCampaignId)}
>
{label}
</Button>
</Tooltip>
)
const fmtMetricValue = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', v: number | null): string => {
if (v == null) return '—'
return metric === 'avg_latency_ms' ? `${Math.round(v)}ms` : fmtPct(v)
}
const fmtMetricDelta = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', d: number | null): string => {
if (d == null) return '—'
const sign = d > 0 ? '+' : ''
return metric === 'avg_latency_ms' ? `${sign}${Math.round(d * 10) / 10}ms` : `${sign}${(d * 100).toFixed(1)}pp`
}
const deltaColor = (metric: 'pass_rate' | 'availability' | 'avg_latency_ms', d: number | null): string => {
if (d == null || d === 0) return colors.textMuted
// 时延下降是好事;比率上升是好事。
const good = metric === 'avg_latency_ms' ? d < 0 : d > 0
return good ? '#52c41a' : '#ff4d4f'
}
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 metricCols = (['pass_rate', 'availability', 'avg_latency_ms'] as const).map((metric) => ({
title: metric === 'pass_rate' ? '通过率' : metric === 'availability' ? '可用性' : '平均时延',
key: metric,
render: (_: unknown, r: typeof rows[number]) => {
const pair = r[metric]
return (
<Space size={4}>
<span style={{ color: colors.textMuted }}>{fmtMetricValue(metric, pair.baseline)}</span>
<span style={{ color: colors.textMuted }}></span>
<span>{fmtMetricValue(metric, pair.current)}</span>
<span style={{ color: deltaColor(metric, pair.delta), fontWeight: 500 }}>
{fmtMetricDelta(metric, pair.delta)}
</span>
</Space>
)
},
}))
return (
<Table
rowKey="key"
size="small"
dataSource={rows}
pagination={false}
columns={[{ title: '维度', dataIndex: 'name', key: 'name' }, ...metricCols]}
style={{ marginBottom: 12 }}
/>
)
}
const renderComparisonSection = () => {
const status = comparison?.status ?? 'none'
if (status === 'generating') {
return (
<div style={analysisBoxStyle}>
<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={comparisonButton('重试', comparison?.comparison?.baseline_campaign_id)}
/>
</div>
)
}
const entry = comparison?.comparison ?? null
if (!entry || !entry.result) {
const autoBaseline = comparison?.auto_baseline ?? null
return (
<div style={{ ...analysisBoxStyle, background: colors.bgSubtle }}>
<div style={{ color: colors.textSecondary, marginBottom: 8 }}>
{autoBaseline
? <>线{autoBaseline.name}{shortDateTime(autoBaseline.completed_at)}</>
: '未找到自动基线(需同计划、正式线、更早完成且已有智能分析的历史活动)。可手动选择基线。'}
</div>
<Space size={12} wrap>
{autoBaseline
? comparisonButton('生成对比')
: (
<>
<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="没有可选的历史活动"
/>
{comparisonButton('生成对比', baselineChoice, true)}
</>
)}
{!comparisonReady && (
<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
return (
<div style={analysisBoxStyle}>
<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)}</>}
{' · '} {comparisonModelName ?? '未知'}
{entry.updated_at && <> · {shortDateTime(entry.updated_at)}</>}
</span>
{comparisonButton('重新生成', 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>
)
}
return ( return (
<PageWrapper <PageWrapper
title="评估活动" title="评估活动"
@ -1217,7 +966,18 @@ export default function CampaignsPage() {
{renderAnalysisSection()} {renderAnalysisSection()}
<SectionTitle><SwapOutlined /> </SectionTitle> <SectionTitle><SwapOutlined /> </SectionTitle>
{renderComparisonSection()} <PeriodComparisonSection
key={report.campaign_id}
comparison={comparison}
terminal={analysisTerminal}
analysisCompleted={analysis?.status === 'completed'}
targetId={report.target_id}
currentCampaignId={report.campaign_id}
campaigns={campaigns}
scenarioNames={scenarioNames}
modelConfigs={data?.modelConfigs ?? []}
onGenerate={generateComparison}
/>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<div style={{ <div style={{