feat(campaigns): add Campaign management page with dual-axis report

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.
This commit is contained in:
sinohqb 2026-07-30 14:09:12 +08:00
parent f433ebb970
commit c82532398b
3 changed files with 527 additions and 0 deletions

View File

@ -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: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <PageLoader><ScenariosPage /></PageLoader> },
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <PageLoader><RunsPage /></PageLoader> },
{ path: '/campaigns', name: '评估活动', icon: <ScheduleOutlined />, component: () => <PageLoader><CampaignsPage /></PageLoader> },
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <PageLoader><OpenClawPage /></PageLoader> },
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },

View File

@ -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<TrendPoint[]>('/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<string, unknown> | 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<Campaign[]>('/campaigns'),
get: (id: string) => api.get<Campaign>(`/campaigns/${id}`),
create: (data: CreateCampaignPayload) => api.post<Campaign>('/campaigns', data),
cancel: (id: string) => api.post<Campaign>(`/campaigns/${id}/cancel`),
report: (id: string) => api.get<CampaignReport>(`/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 {

View File

@ -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<string, { label: string; color: string }> = {
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<Campaign[]>([])
const [targets, setTargets] = useState<Target[]>([])
const [scenarios, setScenarios] = useState<Scenario[]>([])
const [reportMap, setReportMap] = useState<Record<string, CampaignReport>>({})
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<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([])
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<string, CampaignReport> = {}
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 <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
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 (
<Progress
percent={Math.round(rate * 100)} size="small" style={{ width: 120 }}
strokeColor={passRateColor(rate)}
/>
)
},
},
{
title: '操作', key: 'actions',
render: (_: unknown, c: Campaign) => (
<Space>
<Button size="small" icon={<BarChartOutlined />} onClick={() => openReport(c)}></Button>
{(c.status === 'planned' || c.status === 'running') && (
<Popconfirm title="取消该活动?已完成的子运行会保留。" onConfirm={() => cancelCampaign(c.id)}>
<Button size="small" danger icon={<StopOutlined />}></Button>
</Popconfirm>
)}
</Space>
),
},
]
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 ? '—' : (
<Progress percent={Math.round(r.pass_rate * 100)} size="small" style={{ width: 100 }}
strokeColor={passRateColor(r.pass_rate)} />
),
},
{ 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) => <code>{id.slice(0, 8)}</code> },
{
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) => (
<span style={{ color: statusColors[r.status] ?? colors.textMuted }}>
{statusLabels[r.status] ?? r.status}
</span>
),
},
{
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) => (
<Button size="small" type="link" onClick={() => navigate(`/reports?run=${r.id}`)}></Button>
),
},
]
return (
<PageWrapper
title="评估活动"
description="在一个服务周期窗口内按计划持续评测同一对象,聚合成周期报告"
inline
fullHeight
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} />
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
}
>
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
<Table
rowKey="id"
loading={loading}
dataSource={campaigns}
columns={columns}
pagination={false}
locale={{ emptyText: <Empty description="还没有评估活动" /> }}
/>
</div>
{/* 创建活动 */}
<Modal
title="新建评估活动"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={submitCreate}
confirmLoading={submitting}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="活动名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如:数字员工 24h 服务周期" />
</Form.Item>
<Form.Item name="target_id" label="评测对象" rules={[{ required: true, message: '请选择对象' }]}>
<Select
placeholder="选择评测对象"
options={targets.map((t) => ({ label: t.name, value: t.id }))}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="window_seconds" label="窗口长度" rules={[{ required: true }]}>
<Select options={WINDOW_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="time_scale"
label="时间倍速"
tooltip="1=真实墙钟(正式线);开发线可设大倍速把窗口压缩成几分钟"
rules={[{ required: true }]}
>
<InputNumber min={0.001} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<div style={{ marginBottom: 8, color: colors.textSecondary }}></div>
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
{(fields, { add, remove }, { errors }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item
name={[field.name, 'scenario_id']}
rules={[{ required: true, message: '选择场景' }]}
style={{ marginBottom: 0 }}
>
<Select
placeholder="场景"
style={{ width: 220 }}
options={scenarios.map((s) => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
<Form.Item name={[field.name, 'offset_hours']} style={{ marginBottom: 0 }}>
<InputNumber min={0} step={0.5} addonAfter="h偏移" style={{ width: 130 }} />
</Form.Item>
<Form.Item name={[field.name, 'count']} style={{ marginBottom: 0 }}>
<InputNumber min={1} addonAfter="次" style={{ width: 100 }} />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
</Button>
<Form.ErrorList errors={errors} />
</>
)}
</Form.List>
</Form>
</Modal>
{/* 周期报告 */}
<Drawer
title={report ? `活动报告 — ${report.name}` : '活动报告'}
open={reportOpen}
onClose={() => setReportOpen(false)}
width={760}
extra={
report && (
<Button icon={<FileMarkdownOutlined />} onClick={() => campaignsApi.downloadReport(report.campaign_id)}>
Markdown
</Button>
)
}
>
<Spin spinning={reportLoading}>
{report && (
<>
<Row gutter={16}>
<Col span={8}><Statistic title="子运行" value={`${report.summary.completed_runs}/${report.summary.total_runs}`} /></Col>
<Col span={8}>
<Statistic
title="整窗通过率"
value={fmtPct(report.summary.overall_pass_rate)}
valueStyle={{ color: report.summary.overall_pass_rate == null ? undefined : passRateColor(report.summary.overall_pass_rate) }}
/>
</Col>
<Col span={8}><Statistic title="可用性" value={fmtPct(report.summary.overall_availability)} /></Col>
</Row>
<div style={{ marginTop: 8, color: colors.textSecondary }}>
{report.summary.avg_latency_ms == null ? '—' : `${Math.round(report.summary.avg_latency_ms)}ms`}
</div>
<h4 style={{ marginTop: 24 }}></h4>
{trendData.length > 0 ? <Line {...trendConfig} /> : <Empty description="暂无已完成子运行" />}
<h4 style={{ marginTop: 24 }}></h4>
<Table
rowKey="scenario_id"
size="small"
pagination={false}
dataSource={report.capability_summary}
columns={capabilityColumns}
/>
<h4 style={{ marginTop: 24 }}></h4>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={reportRuns}
columns={runColumns}
locale={{ emptyText: <Empty description="暂无子运行" /> }}
/>
</>
)}
</Spin>
</Drawer>
</PageWrapper>
)
}