From c82532398b55081949a2b0ec104f106c6ae67539 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Thu, 30 Jul 2026 14:09:12 +0800 Subject: [PATCH] feat(campaigns): add Campaign management page with dual-axis report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a keep-alive "评估活动" tab that creates campaigns (target, window, time_scale, static plan), lists them with live progress and pass-rate, and opens a report drawer with a time-trend line, capability summary, and drill-down into child Runs. --- frontend/web/src/App.tsx | 3 + frontend/web/src/api.ts | 93 ++++++ frontend/web/src/pages/Campaigns.tsx | 431 +++++++++++++++++++++++++++ 3 files changed, 527 insertions(+) create mode 100644 frontend/web/src/pages/Campaigns.tsx diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index c8337d5..ece73e2 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -11,6 +11,7 @@ import { RobotOutlined, FolderOpenOutlined, CloudServerOutlined, + ScheduleOutlined, LogoutOutlined, } from '@ant-design/icons' import TabBar from './components/TabBar' @@ -28,6 +29,7 @@ const TargetsPage = lazy(() => import('./pages/Targets')) const ScenariosPage = lazy(() => import('./pages/Scenarios')) const RunsPage = lazy(() => import('./pages/Runs')) const ReportsPage = lazy(() => import('./pages/Reports')) +const CampaignsPage = lazy(() => import('./pages/Campaigns')) const OpenClawPage = lazy(() => import('./pages/OpenClaw')) const FilesPage = lazy(() => import('./pages/Files')) const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs')) @@ -57,6 +59,7 @@ const routeConfigs: RouteConfig[] = [ { path: '/models', name: '模型配置', icon: , component: () => }, { path: '/scenarios', name: '评测场景', icon: , component: () => }, { path: '/runs', name: '评测执行', icon: , component: () => }, + { path: '/campaigns', name: '评估活动', icon: , component: () => }, { path: '/reports', name: '评测报告', icon: , component: () => }, { path: '/openclaw', name: 'AI 助手', icon: , component: () => }, { path: '/files', name: '原始文件', icon: , component: () => }, diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 34ea973..a0da90a 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -142,6 +142,7 @@ export interface Run { target_id: string scenario_id: string scenario_version?: number + campaign_id?: string | null status: string triggered_by?: RunTrigger scenario_name?: string | null @@ -283,6 +284,98 @@ export const statsApi = { trend: (days?: number) => api.get('/stats/trend', { params: { days } }), } +// ── Campaigns(评估活动) ────────────────────────────────────────── + +export interface CampaignPlanEntry { + scenario_id: string + offset_seconds: number + count: number +} + +export interface CampaignProgress { + current_offset_seconds: number + spawned_runs: number + completed_runs: number +} + +export interface Campaign { + id: string + name: string + target_id: string + window_seconds: number + time_scale: number + plan: CampaignPlanEntry[] + status: string + started_at: string | null + completed_at: string | null + summary: Record | null + progress?: CampaignProgress +} + +export interface CampaignTrendBucket { + bucket_index: number + start_seconds: number + end_seconds: number + run_count: number + pass_rate: number | null + availability: number | null + avg_latency_ms: number | null +} + +export interface CampaignCapability { + scenario_id: string + scenario_name: string + run_count: number + pass_rate: number | null + availability: number | null + avg_latency_ms: number | null +} + +export interface CampaignReport { + campaign_id: string + name: string + target_id: string + status: string + window_seconds: number + time_scale: number + started_at: string | null + completed_at: string | null + summary: { + total_runs: number + completed_runs: number + overall_pass_rate: number | null + overall_availability: number | null + avg_latency_ms: number | null + } + time_trend: CampaignTrendBucket[] + capability_summary: CampaignCapability[] +} + +export interface CreateCampaignPayload { + name: string + target_id: string + window_seconds: number + time_scale: number + plan: CampaignPlanEntry[] +} + +export const campaignsApi = { + list: () => api.get('/campaigns'), + get: (id: string) => api.get(`/campaigns/${id}`), + create: (data: CreateCampaignPayload) => api.post('/campaigns', data), + cancel: (id: string) => api.post(`/campaigns/${id}/cancel`), + report: (id: string) => api.get(`/campaigns/${id}/report`), + downloadReport: async (id: string) => { + const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) + const url = URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = `campaign-report-${id.slice(0, 8)}.md` + a.click() + URL.revokeObjectURL(url) + }, +} + // ── File Management ────────────────────────────────────────────── export interface FileCategory { diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx new file mode 100644 index 0000000..7841242 --- /dev/null +++ b/frontend/web/src/pages/Campaigns.tsx @@ -0,0 +1,431 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space, + Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, +} from 'antd' +import { + PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, + FileMarkdownOutlined, MinusCircleOutlined, +} from '@ant-design/icons' +import { Line } from '@ant-design/charts' +import PageWrapper from '../components/PageWrapper' +import { + campaignsApi, targetsApi, scenariosApi, runsApi, + type Campaign, type CampaignReport, type Target, type Scenario, type Run, +} from '../api' +import { passRateColor } from '../utils/colors' +import { shortDateTime } from '../utils/date' +import { useOnTabActive } from '../hooks/useOnTabActive' +import { colors, statusColors, statusLabels } from '../tokens' + +const CAMPAIGN_STATUS: Record = { + planned: { label: '计划中', color: 'default' }, + running: { label: '进行中', color: 'processing' }, + completed: { label: '已完成', color: 'success' }, + cancelled: { label: '已取消', color: 'default' }, + failed: { label: '失败', color: 'error' }, +} + +const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 })) + +function fmtWindow(seconds: number): string { + if (seconds % 3600 === 0) return `${seconds / 3600}h` + if (seconds % 60 === 0) return `${seconds / 60}m` + return `${seconds}s` +} + +function fmtPct(v: number | null | undefined): string { + return v == null ? '—' : `${(v * 100).toFixed(1)}%` +} + +interface PlanFormEntry { + scenario_id?: string + offset_hours?: number + count?: number +} + +export default function CampaignsPage() { + const navigate = useNavigate() + const [campaigns, setCampaigns] = useState([]) + const [targets, setTargets] = useState([]) + const [scenarios, setScenarios] = useState([]) + const [reportMap, setReportMap] = useState>({}) + const [loading, setLoading] = useState(false) + + const [createOpen, setCreateOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form] = Form.useForm() + + const [reportOpen, setReportOpen] = useState(false) + const [reportLoading, setReportLoading] = useState(false) + const [report, setReport] = useState(null) + const [reportRuns, setReportRuns] = useState([]) + + const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8) + + const loadData = async () => { + setLoading(true) + try { + const [c, t, s] = await Promise.all([ + campaignsApi.list(), targetsApi.list(), scenariosApi.list(), + ]) + setCampaigns(c.data) + setTargets(t.data) + setScenarios(s.data) + // Fetch each campaign's aggregate report so the list can show progress + // and overall pass_rate at a glance (activity counts are small). + const reports = await Promise.all( + c.data.map((camp) => + campaignsApi.report(camp.id).then((r) => r.data).catch(() => null), + ), + ) + const map: Record = {} + reports.forEach((r) => { if (r) map[r.campaign_id] = r }) + setReportMap(map) + } finally { + setLoading(false) + } + } + + useEffect(() => { loadData() }, []) + useOnTabActive('/campaigns', loadData) + + const openCreate = () => { + form.setFieldsValue({ + name: '', target_id: undefined, window_seconds: 24 * 3600, time_scale: 1, + plan: [{ scenario_id: undefined, offset_hours: 0, count: 1 }], + }) + setCreateOpen(true) + } + + const submitCreate = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + await campaignsApi.create({ + name: values.name, + target_id: values.target_id, + window_seconds: values.window_seconds, + time_scale: values.time_scale, + plan: (values.plan as PlanFormEntry[]).map((e) => ({ + scenario_id: e.scenario_id as string, + offset_seconds: Math.round((e.offset_hours ?? 0) * 3600), + count: e.count ?? 1, + })), + }) + message.success('评估活动已创建并开始调度') + setCreateOpen(false) + loadData() + } finally { + setSubmitting(false) + } + } + + const cancelCampaign = async (id: string) => { + await campaignsApi.cancel(id) + message.success('活动已取消') + loadData() + } + + const openReport = async (campaign: Campaign) => { + setReportOpen(true) + setReportLoading(true) + setReport(null) + setReportRuns([]) + try { + const [rep, runs] = await Promise.all([ + campaignsApi.report(campaign.id), + runsApi.list(), + ]) + setReport(rep.data) + setReportRuns(runs.data.filter((r) => r.campaign_id === campaign.id)) + } finally { + setReportLoading(false) + } + } + + const columns = [ + { title: '名称', dataIndex: 'name', key: 'name' }, + { title: '评测对象', key: 'target', render: (_: unknown, c: Campaign) => targetName(c.target_id) }, + { title: '窗口', key: 'window', render: (_: unknown, c: Campaign) => fmtWindow(c.window_seconds) }, + { + title: '倍速', key: 'scale', + render: (_: unknown, c: Campaign) => (c.time_scale === 1 ? '实时' : `×${c.time_scale}`), + }, + { + title: '状态', key: 'status', + render: (_: unknown, c: Campaign) => { + const meta = CAMPAIGN_STATUS[c.status] ?? { label: c.status, color: 'default' } + return {meta.label} + }, + }, + { + title: '进度', key: 'progress', + render: (_: unknown, c: Campaign) => { + const rep = reportMap[c.id] + if (!rep) return '—' + return `${rep.summary.completed_runs}/${rep.summary.total_runs}` + }, + }, + { + title: '整窗通过率', key: 'pass_rate', + render: (_: unknown, c: Campaign) => { + const rate = reportMap[c.id]?.summary.overall_pass_rate + if (rate == null) return '—' + return ( + + ) + }, + }, + { + title: '操作', key: 'actions', + render: (_: unknown, c: Campaign) => ( + + + {(c.status === 'planned' || c.status === 'running') && ( + cancelCampaign(c.id)}> + + + )} + + ), + }, + ] + + const trendData = (report?.time_trend ?? []) + .filter((b) => b.pass_rate != null) + .map((b) => ({ + slot: `${(b.start_seconds / 3600).toFixed(1)}h`, + value: Math.round((b.pass_rate as number) * 100), + runs: b.run_count, + })) + + const trendConfig = { + data: trendData, + xField: 'slot', + yField: 'value', + smooth: true, + height: 220, + yAxis: { label: { formatter: (v: string) => `${v}%` }, min: 0, max: 100 }, + point: { size: 3 }, + color: colors.primary, + tooltip: { + formatter: (d: { value: number; runs: number }) => ({ + name: '通过率', value: `${d.value}%(${d.runs} 次)`, + }), + }, + } + + const capabilityColumns = [ + { title: '场景', dataIndex: 'scenario_name', key: 'scenario_name' }, + { title: '运行数', dataIndex: 'run_count', key: 'run_count' }, + { + title: '通过率', key: 'pass_rate', + render: (_: unknown, r: { pass_rate: number | null }) => + r.pass_rate == null ? '—' : ( + + ), + }, + { title: '可用性', key: 'availability', render: (_: unknown, r: { availability: number | null }) => fmtPct(r.availability) }, + { + title: '时延', key: 'latency', + render: (_: unknown, r: { avg_latency_ms: number | null }) => + r.avg_latency_ms == null ? '—' : `${Math.round(r.avg_latency_ms)}ms`, + }, + ] + + const runColumns = [ + { title: '子运行', dataIndex: 'id', key: 'id', render: (id: string) => {id.slice(0, 8)} }, + { + title: '场景', key: 'scenario', + render: (_: unknown, r: Run) => scenarios.find((s) => s.id === r.scenario_id)?.name ?? r.scenario_id.slice(0, 8), + }, + { + title: '状态', key: 'status', + render: (_: unknown, r: Run) => ( + + {statusLabels[r.status] ?? r.status} + + ), + }, + { + title: '通过率', key: 'pass_rate', + render: (_: unknown, r: Run) => { + const rate = (r.summary as { pass_rate?: number } | null)?.pass_rate + return rate == null ? '—' : fmtPct(rate) + }, + }, + { title: '时间', key: 'time', render: (_: unknown, r: Run) => shortDateTime(r.started_at) }, + { + title: '', key: 'drill', + render: (_: unknown, r: Run) => ( + + ), + }, + ] + + return ( + + + + } + > +
+ }} + /> + + + {/* 创建活动 */} + setCreateOpen(false)} + onOk={submitCreate} + confirmLoading={submitting} + width={640} + destroyOnClose + > +
+ + + + + + + +
+ + + + + + +
活动计划(在窗口的哪些时段跑哪个场景、跑几次)
+ { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}> + {(fields, { add, remove }, { errors }) => ( + <> + {fields.map((field) => ( + + +
+ +

子运行(点击下钻单次报告)

+
}} + /> + + )} + + + + ) +}