From 602b39d60a3d11de873476c079f9d5056d1df26f Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 19 Aug 2026 17:07:22 +0800 Subject: [PATCH] feat: add interactive timeline legend --- .../operational-timeline-model.test.ts | 54 ++- .../operational-timeline-model.ts | 71 +-- .../operational-timeline.tsx | 409 ++++++++++++------ .../workbench/workspace/artifact-peek-bar.tsx | 2 +- 4 files changed, 379 insertions(+), 157 deletions(-) diff --git a/src/features/workbench/operational-timeline/operational-timeline-model.test.ts b/src/features/workbench/operational-timeline/operational-timeline-model.test.ts index e499e6d..a5176b4 100644 --- a/src/features/workbench/operational-timeline/operational-timeline-model.test.ts +++ b/src/features/workbench/operational-timeline/operational-timeline-model.test.ts @@ -4,15 +4,31 @@ import { canCenterTimelineBesideMapScale, clusterOperationalEvents, createOperationalEvents, + DEFAULT_OPERATIONAL_EVENT_VISIBILITY, + filterOperationalEvents, OPERATIONAL_TIMELINE_LAYOUT, resolveTimelineScaleLayout } from "./operational-timeline-model"; const workOrder: ScheduledConditionItem = { - id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z", - title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1, - durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度", - assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: [] + id: "work-1", + kind: "work_order", + code: "WO-1", + scheduledAt: "2026-08-19T10:00:00.000Z", + title: "阀门调整", + summary: "调整阀门", + status: "running", + riskLevel: "attention", + updatedAt: 1, + durationMinutes: 40, + source: "调度方案", + location: "边界阀门 BV-07", + dispatcher: "调度", + assignee: "班组", + priority: "urgent", + replyWindowMinutes: 20, + stages: [], + replyRequirements: [] }; describe("operational timeline model", () => { @@ -26,13 +42,35 @@ describe("operational timeline model", () => { expect(events[0].correlationId).toBe(events[1].correlationId); }); + it("filters event lanes from the interactive legend", () => { + const events = createOperationalEvents([workOrder]); + const filtered = filterOperationalEvents(events, { + ...DEFAULT_OPERATIONAL_EVENT_VISIBILITY, + actual: false + }); + + expect(filtered.map((event) => event.lane)).toEqual(["plan"]); + }); + it("only promotes abnormal condition runs into exception events", () => { const normal = { - id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1", - scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed", - riskLevel: "normal", updatedAt: 1 + id: "condition-normal", + kind: "condition", + taskId: "scada-diagnosis", + sessionId: "s-1", + scheduledAt: "2026-08-19T10:00:00.000Z", + title: "诊断", + summary: "正常", + status: "completed", + riskLevel: "normal", + updatedAt: 1 + } satisfies ScheduledConditionItem; + const abnormal = { + ...normal, + id: "condition-error", + status: "error", + riskLevel: "critical" } satisfies ScheduledConditionItem; - const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem; expect(createOperationalEvents([normal])).toHaveLength(0); expect(createOperationalEvents([abnormal])[0].lane).toBe("exception"); diff --git a/src/features/workbench/operational-timeline/operational-timeline-model.ts b/src/features/workbench/operational-timeline/operational-timeline-model.ts index 49ffb6a..93a0d0c 100644 --- a/src/features/workbench/operational-timeline/operational-timeline-model.ts +++ b/src/features/workbench/operational-timeline/operational-timeline-model.ts @@ -1,10 +1,15 @@ import type { FeatureTarget } from "../map/workbench-map-controller"; -import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types"; +import type { + ScheduledConditionItem, + ScheduledConditionRecord, + ScheduledWorkOrderItem +} from "../types"; export type OperationalTimelineMode = "compact" | "expanded" | "summary"; export type OperationalEventLane = "plan" | "actual" | "exception"; export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled"; export type OperationalEventImportance = "normal" | "important" | "critical"; +export type OperationalEventVisibility = Record; export type OperationalEvent = { id: string; @@ -42,6 +47,12 @@ export const OPERATIONAL_TIMELINE_LAYOUT = { overlayReservedHeight: 0 } as const; +export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = { + plan: true, + actual: true, + exception: true +}; + export type TimelineScaleLayout = { scaleBottom: number; timelineBottom: number; @@ -54,12 +65,11 @@ export function canCenterTimelineBesideMapScale( ) { if (!visible) return true; const layout = OPERATIONAL_TIMELINE_LAYOUT[mode]; - return businessWorkspaceWidth >= + return ( + businessWorkspaceWidth >= layout.maxWidth + - 2 * ( - OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + - OPERATIONAL_TIMELINE_LAYOUT.floatingGap - ); + 2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap) + ); } export function resolveTimelineScaleLayout( @@ -83,9 +93,11 @@ export function resolveTimelineScaleLayout( } export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] { - return items.flatMap((item) => - item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item) - ).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime)); + return items + .flatMap((item) => + item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item) + ) + .sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime)); } export function clusterOperationalEvents( @@ -121,27 +133,36 @@ export function getOperationalEventSummary(events: OperationalEvent[]) { }; } +export function filterOperationalEvents( + events: OperationalEvent[], + visibility: OperationalEventVisibility +) { + return events.filter((event) => visibility[event.lane]); +} + function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] { if (item.status !== "warning" && item.status !== "error") { return []; } - return [{ - id: `exception-${item.id}`, - title: item.title, - description: item.report?.conclusion ?? item.summary, - lane: "exception", - status: item.status === "error" ? "failed" : "delayed", - importance: item.status === "error" ? "critical" : "important", - plannedTime: item.scheduledAt, - actualTime: item.scheduledAt, - cursorTime: item.scheduledAt, - correlationId: item.sessionId, - scheduleId: item.taskId, - conditionId: item.id, - sourceLabel: "工况诊断", - mapTargets: getConditionMapTargets(item) - }]; + return [ + { + id: `exception-${item.id}`, + title: item.title, + description: item.report?.conclusion ?? item.summary, + lane: "exception", + status: item.status === "error" ? "failed" : "delayed", + importance: item.status === "error" ? "critical" : "important", + plannedTime: item.scheduledAt, + actualTime: item.scheduledAt, + cursorTime: item.scheduledAt, + correlationId: item.sessionId, + scheduleId: item.taskId, + conditionId: item.id, + sourceLabel: "工况诊断", + mapTargets: getConditionMapTargets(item) + } + ]; } function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] { diff --git a/src/features/workbench/operational-timeline/operational-timeline.tsx b/src/features/workbench/operational-timeline/operational-timeline.tsx index 9abc7b6..1bcabfd 100644 --- a/src/features/workbench/operational-timeline/operational-timeline.tsx +++ b/src/features/workbench/operational-timeline/operational-timeline.tsx @@ -2,6 +2,8 @@ import { CalendarClock, Check, Clock3, + Eye, + EyeOff, OctagonAlert, PanelBottomClose, PanelBottomOpen, @@ -9,15 +11,19 @@ import { TriangleAlert } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useState } from "react"; import { cn } from "@/shared/ui/cn"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { clusterOperationalEvents, + DEFAULT_OPERATIONAL_EVENT_VISIBILITY, + filterOperationalEvents, getOperationalEventSummary, OPERATIONAL_TIMELINE_LAYOUT, type OperationalEvent, type OperationalEventCluster, type OperationalEventLane, + type OperationalEventVisibility, type OperationalTimelineMode } from "./operational-timeline-model"; @@ -53,8 +59,12 @@ export function OperationalTimeline({ onReturnNow }: OperationalTimelineProps) { const prefersReducedMotion = useReducedMotion(); + const [eventVisibility, setEventVisibility] = useState(() => ({ + ...DEFAULT_OPERATIONAL_EVENT_VISIBILITY + })); const summary = getOperationalEventSummary(events); - const clusters = clusterOperationalEvents(events, mobile ? 60 : 10); + const visibleEvents = filterOperationalEvents(events, eventVisibility); + const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10); const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null; const cursorPosition = getDayPosition(cursorTime); const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height; @@ -93,112 +103,128 @@ export function OperationalTimeline({ return ( - -
+ + -
-
- - - {selectedEvent && !mobile ? ( - - ) : null} - -
- - - {mode === "expanded" || mobile ? ( - -
- - {LANES.map((lane) => ( - {lane.label} - ))} -
-
- - {LANES.map((lane) => ( - cluster.lane === lane.id)} - selectedEventId={selectedEventId} - onSelectEvent={onSelectEvent} - /> - ))} -
- +
+
- - ) : ( - - -
-
- {clusters.map((cluster, index) => ( - - ))} -
-
- - )} - - + + + {selectedEvent && !mobile ? ( + + ) : null} + +
+ + + {mode === "expanded" || mobile ? ( + +
+ + {LANES.map((lane) => ( + + {lane.label} + + ))} +
+
+ + {LANES.map((lane) => ( + cluster.lane === lane.id)} + selectedEventId={selectedEventId} + onSelectEvent={onSelectEvent} + /> + ))} +
+ +
+
+
+ ) : ( + + +
+
+ {clusters.map((cluster, index) => ( + + ))} +
+
+ + )} + + +
); } @@ -262,7 +288,8 @@ function TimelineMarker({ "rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]", event.lane === "actual" && "rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]", - exception && event.status !== "failed" && + exception && + event.status !== "failed" && "rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]", event.status === "failed" && "rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]", @@ -279,23 +306,82 @@ function TimelineMarker({ ) : null} - -
- - {operationalEventNatureLabel(event)} - - {event.sourceLabel} + +
+ +
+
+ + + +
+
+ + {operationalEventNatureLabel(event)} + + +
+

{event.sourceLabel}

+
+
+

+ {event.title} +

+

+ {event.description} +

+
+ + {cluster.events.length > 1 ? `同窗 ${cluster.events.length} 项` : "单项事件"} + + {event.conditionId ? ( + 查看工况报告 → + ) : ( + + {event.correlationId ?? event.scheduleId} + + )} +
+
-

{formatClock(event.cursorTime)} · {event.title}

-

{event.description}

- {cluster.events.length > 1 ?

同一时间窗共 {cluster.events.length} 项

: null} - {event.conditionId ?

点击查看对应工况报告

: null}
); @@ -324,9 +410,15 @@ function operationalEventNatureLabel(event: OperationalEvent) { function TimelineSummary({ summary }: { summary: ReturnType }) { return (
- 计划 {summary.planned} - 执行 {summary.active} - 异常 {summary.exceptions} + + 计划 {summary.planned} + + + 执行 {summary.active} + + + 异常 {summary.exceptions} +
); } @@ -375,6 +467,71 @@ function TimelineActions({ ); } +function TimelineLegend({ + mobile, + visibility, + onVisibilityChange +}: { + mobile: boolean; + visibility: OperationalEventVisibility; + onVisibilityChange: (visibility: OperationalEventVisibility) => void; +}) { + return ( +
+ 图例 + {LANES.map((lane) => { + const visible = visibility[lane.id]; + return ( + + ); + })} +
+ ); +} + +function LegendSwatch({ lane }: { lane: OperationalEventLane }) { + return ( + + ); +} + function TimeTicks() { return (
@@ -414,7 +571,9 @@ function CompactTimeTicks() { } function pickClusterEvent(events: OperationalEvent[]) { - return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0]; + return [...events].sort( + (left, right) => importanceRank(right.importance) - importanceRank(left.importance) + )[0]; } function importanceRank(value: OperationalEvent["importance"]) { @@ -428,7 +587,11 @@ function getDayPosition(value: string) { } function formatClock(value: string) { - return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(value)); } function formatDate(value: string) { diff --git a/src/features/workbench/workspace/artifact-peek-bar.tsx b/src/features/workbench/workspace/artifact-peek-bar.tsx index b422ce1..8991928 100644 --- a/src/features/workbench/workspace/artifact-peek-bar.tsx +++ b/src/features/workbench/workspace/artifact-peek-bar.tsx @@ -17,7 +17,7 @@ export function ArtifactPeekBar({