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:
parent
1e55a21649
commit
dd3b9a5e91
@ -1,6 +1,6 @@
|
||||
# 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:** 02(API 与结果结构)
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
- [x] `api.ts`:对比类型 + `getComparison` / `generateComparison(id, baselineId?)`
|
||||
- [x] 区块按状态渲染(生成中轮询复用 `usePolling`)
|
||||
- [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(正绿负红)/ 问题演化 Tag / 建议追踪状态
|
||||
- [x] 已生成渲染:趋势徽章 + 总结 / 指标 delta 表(按好坏着色)/ 问题演化 Tag / 建议追踪状态
|
||||
- [x] 无自动基线时的基线选择器(同对象终态活动下拉)
|
||||
- [x] 基线活动名 + 完成时间 + 模型名 + 生成时间展示;按钮终态门控
|
||||
- [x] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证
|
||||
|
||||
@ -57,14 +57,14 @@
|
||||
|
||||
### 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"}`
|
||||
- 两期都有 completed 分析是生成前提(基线无分析 → 400 提示先生成基线分析)
|
||||
|
||||
### 前端(报告抽屉「周期对比」区块)
|
||||
|
||||
- 位置:「智能分析」区块之下,复用区块状态行范式(生成中 5s 轮询 / 失败重试 / 未生成按钮)
|
||||
- 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta,正绿负红)→ 问题演化(新增红/持续橙/消解绿 Tag)→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝)
|
||||
- 已生成:趋势徽章 + 总结 → 指标变化表(整窗 + 分场景 delta,按好坏着色——比率升为绿、时延降为绿,反之红)→ 问题演化(新增红/持续橙/消解绿 Tag)→ 建议追踪(已落实绿/部分橙/未落实灰/新增蓝)
|
||||
- 无自动基线:基线选择器(同对象历史终态活动下拉)+ 生成按钮
|
||||
- 展示基线活动名与完成时间、所用分析模型名与生成时间
|
||||
|
||||
|
||||
@ -262,7 +262,8 @@ def _maybe_enqueue_period_comparison(campaign: Campaign, session: Session) -> No
|
||||
_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)
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
|
||||
report = generate_campaign_report(campaign, runs, scenario_names=scenario_names)
|
||||
|
||||
@ -14,11 +14,9 @@ from typing import Any, Optional
|
||||
|
||||
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.model_gateway import ModelGateway
|
||||
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.repository import (
|
||||
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(
|
||||
campaign_id: str,
|
||||
*,
|
||||
@ -378,7 +367,7 @@ async def execute_campaign_comparison(
|
||||
current_analysis=current_analysis.get_result(),
|
||||
metric_diff=diff,
|
||||
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:
|
||||
_logger.warning("活动 %s 周期对比失败: %s", campaign_id, exc)
|
||||
|
||||
286
frontend/web/src/components/PeriodComparisonSection.tsx
Normal file
286
frontend/web/src/components/PeriodComparisonSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -17,13 +17,14 @@ import {
|
||||
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
|
||||
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
|
||||
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
|
||||
type CampaignComparison, type MetricDiff,
|
||||
type CampaignComparison,
|
||||
} from '../api'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
import { shortDateTime, toDate } from '../utils/date'
|
||||
import { deriveTimeScale, acceleratedDuration, formatScale } from '../utils/campaignTime'
|
||||
import WindowTimeline, { type TimelineMarker } from '../components/WindowTimeline'
|
||||
import CampaignRunTimeline from '../components/CampaignRunTimeline'
|
||||
import PeriodComparisonSection from '../components/PeriodComparisonSection'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
@ -43,25 +44,6 @@ const SEVERITY_META: Record<string, { label: string; color: string }> = {
|
||||
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 UNIT_OPTIONS = [
|
||||
@ -146,8 +128,6 @@ export default function CampaignsPage() {
|
||||
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
|
||||
const [analysisBusy, setAnalysisBusy] = useState(false)
|
||||
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 [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
|
||||
@ -257,7 +237,6 @@ export default function CampaignsPage() {
|
||||
setReportTimeline([])
|
||||
setAnalysis(null)
|
||||
setComparison(null)
|
||||
setBaselineChoice(undefined)
|
||||
}
|
||||
try {
|
||||
const [rep, runs, tl, ana, cmp] = await Promise.all([
|
||||
@ -317,14 +296,9 @@ export default function CampaignsPage() {
|
||||
|
||||
const generateComparison = async (baselineCampaignId?: string) => {
|
||||
if (!reportId) return
|
||||
setComparisonBusy(true)
|
||||
try {
|
||||
await campaignsApi.generateComparison(reportId, baselineCampaignId)
|
||||
const res = await campaignsApi.getComparison(reportId)
|
||||
setComparison(res.data)
|
||||
} finally {
|
||||
setComparisonBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 对比生成中同样轮询,直到 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 (
|
||||
<PageWrapper
|
||||
title="评估活动"
|
||||
@ -1217,7 +966,18 @@ export default function CampaignsPage() {
|
||||
{renderAnalysisSection()}
|
||||
|
||||
<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>
|
||||
<div style={{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user