feat(campaign): live plan timeline preview in create form

Above the plan editor, adapt each plan entry (offset, scenario, count) into
WindowTimeline markers coloured per scenario with a count badge, re-rendering
live as the form changes via Form.useWatch. Empty plan shows a placeholder.
Purely form-local, no backend change. (v0.6 ticket 09)
This commit is contained in:
sinohqb 2026-07-31 16:59:03 +08:00
parent f4db90a989
commit b295f7370d

View File

@ -17,6 +17,7 @@ import {
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date' import { shortDateTime } 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 { 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'
@ -99,6 +100,25 @@ export default function CampaignsPage() {
const campaigns = data?.campaigns ?? [] const campaigns = data?.campaigns ?? []
const targets = data?.targets ?? [] const targets = data?.targets ?? []
const scenarios = data?.scenarios ?? [] const scenarios = data?.scenarios ?? []
const scenarioNames = Object.fromEntries(scenarios.map((s) => [s.id, s.name]))
const wPlan = (Form.useWatch('plan', form) as PlanFormEntry[] | undefined) ?? []
const planMarkers: TimelineMarker[] = wPlan
.map((e, i) => ({ e, i }))
.filter(({ e }) => e && e.scenario_id)
.map(({ e, i }) => {
const name = scenarioNames[e.scenario_id as string] ?? (e.scenario_id as string).slice(0, 8)
const count = e.count ?? 1
const offsetHours = e.offset_hours ?? 0
return {
key: `plan-${i}`,
offsetSeconds: offsetHours * 3600,
colorKey: e.scenario_id as string,
label: name,
badge: count > 1 ? `×${count}` : undefined,
tooltip: `${name} · +${offsetHours}h · ${count}`,
}
})
// Poll the list while this tab is active and a campaign is still working — // Poll the list while this tab is active and a campaign is still working —
// compressed dev-line campaigns change fast. Stop once all are terminal. // compressed dev-line campaigns change fast. Stop once all are terminal.
@ -412,6 +432,25 @@ export default function CampaignsPage() {
{acceleratedDuration(wWindow, derivedScale)} {acceleratedDuration(wWindow, derivedScale)}
</div> </div>
<div style={{ marginBottom: 4, color: colors.textSecondary }}></div>
<div style={{
border: `1px solid ${colors.border}`, borderRadius: 8,
padding: '12px 8px', marginBottom: 12,
}}>
{planMarkers.length > 0 ? (
<WindowTimeline
windowSeconds={wWindow}
markers={planMarkers}
colorLabels={scenarioNames}
showLegend
/>
) : (
<div style={{ textAlign: 'center', color: colors.textMuted, padding: '12px 0' }}>
</div>
)}
</div>
<div style={{ marginBottom: 8, color: colors.textSecondary }}></div> <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('至少一条计划条目')) } }]}> <Form.List name="plan" rules={[{ validator: async (_, plan) => { if (!plan || plan.length < 1) return Promise.reject(new Error('至少一条计划条目')) } }]}>
{(fields, { add, remove }, { errors }) => ( {(fields, { add, remove }, { errors }) => (