diff --git a/frontend/web/src/components/WindowTimeline.tsx b/frontend/web/src/components/WindowTimeline.tsx index 15bf84f..9bc8145 100644 --- a/frontend/web/src/components/WindowTimeline.tsx +++ b/frontend/web/src/components/WindowTimeline.tsx @@ -26,6 +26,8 @@ interface WindowTimelineProps { colorLabels?: Record showLegend?: boolean height?: number + /** Spread markers that would overlap onto separate vertical rows. */ + stagger?: boolean } const PALETTE = [ @@ -35,6 +37,10 @@ const PALETTE = [ const TICK_COUNT = 6 +/** Markers closer than this fraction of the window collide and need their own row. */ +const STAGGER_GAP = 0.04 +const STAGGER_ROW_PX = 16 + export function fmtTick(seconds: number): string { if (seconds < 3600) return `${Math.round(seconds / 60)}m` const h = seconds / 3600 @@ -42,7 +48,7 @@ export function fmtTick(seconds: number): string { } export default function WindowTimeline({ - windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, + windowSeconds, markers, colorMap, colorLabels, showLegend = false, height = 64, stagger = false, }: WindowTimelineProps) { const window = windowSeconds > 0 ? windowSeconds : 1 const uniqueKeys = Array.from(new Set(markers.map((m) => m.colorKey))).sort() @@ -50,11 +56,33 @@ export default function WindowTimeline({ colorMap?.[key] ?? PALETTE[Math.max(0, uniqueKeys.indexOf(key)) % PALETTE.length] const leftPct = (offset: number) => `${Math.min(1, Math.max(0, offset / window)) * 100}%` + // Greedy row assignment in offset order: a marker joins the first row whose + // previous marker is far enough away, otherwise it opens a new row. + const slotOf: number[] = markers.map(() => 0) + let rowCount = 1 + if (stagger) { + const lastEnd: number[] = [] + const order = markers.map((_, i) => i).sort((a, b) => markers[a].offsetSeconds - markers[b].offsetSeconds) + for (const i of order) { + const off = markers[i].offsetSeconds + let s = lastEnd.findIndex((last) => off - last >= STAGGER_GAP * window) + if (s === -1) { + s = lastEnd.length + lastEnd.push(0) + } + lastEnd[s] = off + slotOf[i] = s + } + rowCount = Math.max(1, lastEnd.length) + } + const rowY = (slot: number) => (slot - (rowCount - 1) / 2) * STAGGER_ROW_PX + const trackHeight = rowCount > 1 ? 24 + rowCount * STAGGER_ROW_PX : height + const ticks = Array.from({ length: TICK_COUNT + 1 }, (_, i) => i / TICK_COUNT) return (
-
+
))} - {markers.map((m) => { + {markers.map((m, idx) => { const c = colorOf(m.colorKey) return ( @@ -83,7 +111,7 @@ export default function WindowTimeline({ onClick={m.onClick} style={{ position: 'absolute', left: leftPct(m.offsetSeconds), top: '50%', - transform: 'translate(-50%, -50%)', + transform: `translate(-50%, -50%)${stagger ? ` translateY(${rowY(slotOf[idx])}px)` : ''}`, cursor: m.onClick ? 'pointer' : 'default', }} > diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index a0c0661..fb87ca3 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -130,8 +130,6 @@ export default function CampaignsPage() { key: `plan-${i}`, offsetSeconds: offsetHours * 3600, colorKey: e.scenario_id as string, - label: name, - badge: count > 1 ? `×${count}` : undefined, tooltip: `${name} · +${offsetHours}h · ${count} 次`, } }) @@ -455,7 +453,15 @@ export default function CampaignsPage() { tooltip="服务对象的一个完整服务周期(如早 8 点到次日早 8 点 = 24 小时),所有计划条目都落在窗口内" rules={[{ required: true }]} > - { + // Shrinking the window can invalidate existing plan offsets — + // re-validate them so the errors surface immediately. + const plan = (form.getFieldValue('plan') as PlanFormEntry[] | undefined) ?? [] + form.validateFields(plan.map((_, i) => ['plan', i, 'offset_hours'])).catch(() => {}) + }} + /> ) : (
@@ -559,8 +566,24 @@ export default function CampaignsPage() { options={scenarios.map((s) => ({ label: s.name, value: s.id }))} /> - - + { + if (v == null) return + const win = (form.getFieldValue('window_seconds') as number | undefined) ?? 0 + if (win > 0 && v * 3600 > win) { + throw new Error(`不能超过窗口长度(${fmtWindow(win)})`) + } + }, + }]} + > + 0 ? wWindow / 3600 : undefined} + style={{ width: '100%' }} + />