feat(campaign): drawer create form and scenario-lane run timeline

Create surface moves from a 640px modal to a 920px two-column drawer:
basic info + time/speed on the left, plan preview and a grid-aligned
plan editor (headers, searchable scenario selects, scrollable entries)
on the right, so many-entry plans stay editable. The expanded-row
process timeline switches from a single crowded axis to per-scenario
lanes with a now-line for active campaigns.
This commit is contained in:
sinohqb 2026-08-02 00:58:03 +08:00
parent 67a574da18
commit 3aac5e8068
3 changed files with 312 additions and 131 deletions

View File

@ -0,0 +1,143 @@
import { Tooltip } from 'antd'
import { useNavigate } from 'react-router-dom'
import type { CampaignTimelineEntry } from '../api'
import { colors, fontSizes, statusColors, statusLabels } from '../tokens'
import { fmtTick } from './WindowTimeline'
import { shortDateTime } from '../utils/date'
// Expanded-row view of a campaign: one lane per scenario under a shared window
// axis, each child Run a status-coloured node. For still-active campaigns a
// dashed line marks how far the window has progressed (nowOffsetSeconds).
interface CampaignRunTimelineProps {
windowSeconds: number
entries: CampaignTimelineEntry[]
nowOffsetSeconds?: number | null
}
const TICK_COUNT = 6
const LANE_HEIGHT = 30
const GUTTER = 140
const TRACK_RIGHT = 12
export default function CampaignRunTimeline({
windowSeconds, entries, nowOffsetSeconds,
}: CampaignRunTimelineProps) {
const navigate = useNavigate()
const window = windowSeconds > 0 ? windowSeconds : 1
const clampPct = (offset: number) => Math.min(1, Math.max(0, offset / window)) * 100
// Entries arrive sorted by offset — lanes appear in chronological first-use order.
const lanes: { key: string; name: string; items: CampaignTimelineEntry[] }[] = []
for (const e of entries) {
let lane = lanes.find((l) => l.key === e.scenario_id)
if (!lane) {
lane = { key: e.scenario_id, name: e.scenario_name, items: [] }
lanes.push(lane)
}
lane.items.push(e)
}
const presentStatuses = Array.from(new Set(entries.map((e) => e.status))).sort()
const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT)
const nowPct = nowOffsetSeconds == null ? null : clampPct(nowOffsetSeconds)
return (
<div>
<div style={{ position: 'relative', height: 16, marginLeft: GUTTER, marginRight: TRACK_RIGHT }}>
{ticks.map((t) => (
<span
key={t}
style={{
position: 'absolute', left: `${t * 100}%`, transform: 'translateX(-50%)',
fontSize: fontSizes.meta, color: colors.textMuted, whiteSpace: 'nowrap',
}}
>
{fmtTick(t * window)}
</span>
))}
</div>
<div style={{ position: 'relative' }}>
{nowPct != null && (
<div style={{
position: 'absolute', top: 0, bottom: 0,
left: GUTTER, right: TRACK_RIGHT, pointerEvents: 'none',
}}>
<div style={{
position: 'absolute', left: `${nowPct}%`, top: -2, bottom: -2,
width: 0, borderLeft: `1px dashed ${colors.primary}`,
}} />
</div>
)}
{lanes.map((lane) => (
<div key={lane.key} style={{ display: 'flex', alignItems: 'center', height: LANE_HEIGHT }}>
<div
title={lane.name}
style={{
width: GUTTER, flexShrink: 0, paddingRight: 10,
fontSize: fontSizes.body, color: colors.text,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}
>
{lane.name}
</div>
<div style={{ position: 'relative', flex: 1, height: '100%', marginRight: TRACK_RIGHT }}>
<div style={{
position: 'absolute', left: 0, right: 0, top: '50%',
height: 2, marginTop: -1, background: colors.border,
}} />
{lane.items.map((e) => (
<Tooltip
key={e.run_id}
title={
<div style={{ fontSize: 12 }}>
<div>{e.scenario_name} · {statusLabels[e.status] ?? e.status}</div>
<div>{e.pass_rate == null ? '—' : `${(e.pass_rate * 100).toFixed(1)}%`}</div>
<div>{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
<div>{shortDateTime(e.started_at)}</div>
</div>
}
>
<span
onClick={() => navigate(`/reports?run=${e.run_id}`)}
style={{
position: 'absolute', left: `${clampPct(e.offset_seconds)}%`, top: '50%',
transform: 'translate(-50%, -50%)',
width: 12, height: 12, borderRadius: '50%',
background: statusColors[e.status] ?? colors.textMuted,
border: '2px solid #fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.1)',
cursor: 'pointer',
}}
/>
</Tooltip>
))}
</div>
</div>
))}
</div>
<div style={{
display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 6,
fontSize: fontSizes.meta, color: colors.textSecondary,
}}>
{presentStatuses.map((s) => (
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: statusColors[s] ?? colors.textMuted,
}} />
{statusLabels[s] ?? s}
</span>
))}
{nowPct != null && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 0, height: 10, borderLeft: `1px dashed ${colors.primary}` }} />
</span>
)}
<span style={{ marginLeft: 'auto', color: colors.textMuted }}></span>
</div>
</div>
)
}

View File

@ -35,7 +35,7 @@ const PALETTE = [
const TICK_COUNT = 6
function fmtTick(seconds: number): string {
export function fmtTick(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`
const h = seconds / 3600
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`

View File

@ -1,12 +1,12 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip,
Popconfirm, Drawer, Row, Col, Statistic, Progress, Empty, Spin, message, Switch,
} from 'antd'
import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
FileMarkdownOutlined, MinusCircleOutlined,
FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined,
} from '@ant-design/icons'
import { Line } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper'
@ -16,9 +16,10 @@ import {
type CampaignTimelineEntry,
} from '../api'
import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date'
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 { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling'
import { useTabStore } from '../stores/tabStore'
@ -48,6 +49,17 @@ const POLL_INTERVAL_MS = 5000
const isActiveStatus = (status: string) => status === 'planned' || status === 'running'
/** How far an active campaign's window has progressed, in window seconds. */
function nowOffsetFor(c: CampaignListItem): number | null {
if (!isActiveStatus(c.status) || !c.started_at) return null
const elapsed = (Date.now() - toDate(c.started_at).getTime()) / 1000
return Math.min(Math.max(elapsed * c.time_scale, 0), c.window_seconds)
}
function SectionTitle({ children }: { children: ReactNode }) {
return <div style={{ fontWeight: 600, fontSize: 13, margin: '4px 0 12px' }}>{children}</div>
}
function fmtWindow(seconds: number): string {
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
@ -366,26 +378,11 @@ export default function CampaignsPage() {
const entries = timelines[c.id]
if (entries === undefined) return <Spin />
if (entries.length === 0) return <Empty description="暂无子运行" image={Empty.PRESENTED_IMAGE_SIMPLE} />
const markers: TimelineMarker[] = entries.map((e) => ({
key: e.run_id,
offsetSeconds: e.offset_seconds,
colorKey: e.status,
tooltip: (
<div style={{ fontSize: 12 }}>
<div>{e.scenario_name}</div>
<div>{fmtPct(e.pass_rate)}</div>
<div>{e.avg_latency_ms == null ? '—' : `${Math.round(e.avg_latency_ms)}ms`}</div>
</div>
),
onClick: () => navigate(`/reports?run=${e.run_id}`),
}))
return (
<WindowTimeline
<CampaignRunTimeline
windowSeconds={c.window_seconds}
markers={markers}
colorMap={statusColors}
colorLabels={statusLabels}
showLegend
entries={entries}
nowOffsetSeconds={nowOffsetFor(c)}
/>
)
}
@ -420,32 +417,46 @@ export default function CampaignsPage() {
</div>
{/* 创建活动 */}
<Modal
<Drawer
title="新建评估活动"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={submitCreate}
confirmLoading={submitting}
width={640}
onClose={() => setCreateOpen(false)}
width={920}
destroyOnClose
footer={
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={() => setCreateOpen(false)}></Button>
<Button type="primary" loading={submitting} onClick={submitCreate}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Row gutter={32}>
<Col span={9}>
<SectionTitle></SectionTitle>
<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="选择评测对象"
showSearch
optionFilterProp="label"
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 }]}>
<SectionTitle></SectionTitle>
<Form.Item
name="window_seconds"
label="窗口长度"
tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内"
rules={[{ required: true }]}
>
<Select options={WINDOW_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="realtime"
label="实时(真实墙钟)"
@ -454,9 +465,6 @@ export default function CampaignsPage() {
>
<Switch checkedChildren="实时" unCheckedChildren="加速" />
</Form.Item>
</Col>
</Row>
<Form.Item
label="希望加速后多久跑完"
tooltip="按“耗时”思考:例如 24 小时窗口希望 1 小时内跑完 → 自动换算为 ×24"
@ -483,17 +491,26 @@ export default function CampaignsPage() {
</Form.Item>
</Space>
</Form.Item>
<div style={{ marginBottom: 16, color: colors.textSecondary }}>
<b>{wRealtime ? '×1实时' : formatScale(derivedScale)}</b>
{' · '}
{acceleratedDuration(wWindow, derivedScale)}
<div style={{
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
padding: '8px 12px', fontSize: 12, color: colors.text,
}}>
{wRealtime
? `实时模式:按真实墙钟推进,计划贯穿整个 ${fmtWindow(wWindow)} 窗口`
: (
<>
{fmtWindow(wWindow)} · <b>{formatScale(derivedScale)}</b>
{' · '}{acceleratedDuration(wWindow, derivedScale)}
</>
)}
</div>
</Col>
<div style={{ marginBottom: 4, color: colors.textSecondary }}></div>
<Col span={15}>
<SectionTitle></SectionTitle>
<div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 8px', marginBottom: 12,
padding: '12px 8px', marginBottom: 16,
}}>
{planMarkers.length > 0 ? (
<WindowTimeline
@ -504,39 +521,58 @@ export default function CampaignsPage() {
/>
) : (
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
</div>
)}
</div>
<div style={{ marginBottom: 8, color: colors.textSecondary }}></div>
<SectionTitle></SectionTitle>
<div style={{ marginBottom: 8, fontSize: 12, color: colors.textSecondary }}>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, fontSize: 12, color: colors.textSecondary }}>
<span style={{ flex: 1 }}></span>
<span style={{ width: 140 }}>
{' '}
<Tooltip title="该场景在窗口开始后第几个小时启动">
<QuestionCircleOutlined style={{ color: colors.textMuted }} />
</Tooltip>
</span>
<span style={{ width: 104 }}></span>
<span style={{ width: 16 }} />
</div>
<Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
{(fields, { add, remove }, { errors }) => (
<>
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{fields.map((field) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 8 }}>
<Form.Item
name={[field.name, 'scenario_id']}
rules={[{ required: true, message: '选择场景' }]}
style={{ marginBottom: 0 }}
style={{ flex: 1, marginBottom: 0 }}
>
<Select
placeholder="场景"
style={{ width: 220 }}
placeholder="选择场景"
showSearch
optionFilterProp="label"
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 name={[field.name, 'offset_hours']} style={{ width: 140, marginBottom: 0 }}>
<InputNumber min={0} step={0.5} addonAfter="小时后" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={[field.name, 'count']} style={{ marginBottom: 0 }}>
<InputNumber min={1} addonAfter="次" style={{ width: 100 }} />
<Form.Item name={[field.name, 'count']} style={{ width: 104, marginBottom: 0 }}>
<InputNumber min={1} addonAfter="次" style={{ width: '100%' }} />
</Form.Item>
<div style={{ width: 16, lineHeight: '32px' }}>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: colors.textMuted }} />
)}
</Space>
</div>
</div>
))}
</div>
<Button type="dashed" onClick={() => add({ scenario_id: undefined, offset_hours: 0, count: 1 })} block icon={<PlusOutlined />}>
</Button>
@ -544,8 +580,10 @@ export default function CampaignsPage() {
</>
)}
</Form.List>
</Col>
</Row>
</Form>
</Modal>
</Drawer>
{/* 周期报告 */}
<Drawer