feat: add interactive timeline legend

This commit is contained in:
2026-08-19 17:07:22 +08:00
parent 5e7101bfee
commit 602b39d60a
4 changed files with 379 additions and 157 deletions
@@ -4,15 +4,31 @@ import {
canCenterTimelineBesideMapScale, canCenterTimelineBesideMapScale,
clusterOperationalEvents, clusterOperationalEvents,
createOperationalEvents, createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
OPERATIONAL_TIMELINE_LAYOUT, OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout resolveTimelineScaleLayout
} from "./operational-timeline-model"; } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = { const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z", id: "work-1",
title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1, kind: "work_order",
durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度", code: "WO-1",
assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: [] 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", () => { describe("operational timeline model", () => {
@@ -26,13 +42,35 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId); 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", () => { it("only promotes abnormal condition runs into exception events", () => {
const normal = { const normal = {
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1", id: "condition-normal",
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed", kind: "condition",
riskLevel: "normal", updatedAt: 1 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; } satisfies ScheduledConditionItem;
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0); expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception"); expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
@@ -1,10 +1,15 @@
import type { FeatureTarget } from "../map/workbench-map-controller"; 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 OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception"; export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled"; export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical"; export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventVisibility = Record<OperationalEventLane, boolean>;
export type OperationalEvent = { export type OperationalEvent = {
id: string; id: string;
@@ -42,6 +47,12 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
overlayReservedHeight: 0 overlayReservedHeight: 0
} as const; } as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
exception: true
};
export type TimelineScaleLayout = { export type TimelineScaleLayout = {
scaleBottom: number; scaleBottom: number;
timelineBottom: number; timelineBottom: number;
@@ -54,12 +65,11 @@ export function canCenterTimelineBesideMapScale(
) { ) {
if (!visible) return true; if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode]; const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return businessWorkspaceWidth >= return (
businessWorkspaceWidth >=
layout.maxWidth + layout.maxWidth +
2 * ( 2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + );
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
);
} }
export function resolveTimelineScaleLayout( export function resolveTimelineScaleLayout(
@@ -83,9 +93,11 @@ export function resolveTimelineScaleLayout(
} }
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] { export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) => return items
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item) .flatMap((item) =>
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime)); item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
)
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
} }
export function clusterOperationalEvents( 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[] { function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") { if (item.status !== "warning" && item.status !== "error") {
return []; return [];
} }
return [{ return [
id: `exception-${item.id}`, {
title: item.title, id: `exception-${item.id}`,
description: item.report?.conclusion ?? item.summary, title: item.title,
lane: "exception", description: item.report?.conclusion ?? item.summary,
status: item.status === "error" ? "failed" : "delayed", lane: "exception",
importance: item.status === "error" ? "critical" : "important", status: item.status === "error" ? "failed" : "delayed",
plannedTime: item.scheduledAt, importance: item.status === "error" ? "critical" : "important",
actualTime: item.scheduledAt, plannedTime: item.scheduledAt,
cursorTime: item.scheduledAt, actualTime: item.scheduledAt,
correlationId: item.sessionId, cursorTime: item.scheduledAt,
scheduleId: item.taskId, correlationId: item.sessionId,
conditionId: item.id, scheduleId: item.taskId,
sourceLabel: "工况诊断", conditionId: item.id,
mapTargets: getConditionMapTargets(item) sourceLabel: "工况诊断",
}]; mapTargets: getConditionMapTargets(item)
}
];
} }
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] { function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
@@ -2,6 +2,8 @@ import {
CalendarClock, CalendarClock,
Check, Check,
Clock3, Clock3,
Eye,
EyeOff,
OctagonAlert, OctagonAlert,
PanelBottomClose, PanelBottomClose,
PanelBottomOpen, PanelBottomOpen,
@@ -9,15 +11,19 @@ import {
TriangleAlert TriangleAlert
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { import {
clusterOperationalEvents, clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary, getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT, OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent, type OperationalEvent,
type OperationalEventCluster, type OperationalEventCluster,
type OperationalEventLane, type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode type OperationalTimelineMode
} from "./operational-timeline-model"; } from "./operational-timeline-model";
@@ -53,8 +59,12 @@ export function OperationalTimeline({
onReturnNow onReturnNow
}: OperationalTimelineProps) { }: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events); 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 selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime); const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height; const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
@@ -93,112 +103,128 @@ export function OperationalTimeline({
return ( return (
<TooltipProvider delayDuration={180}> <TooltipProvider delayDuration={180}>
<motion.section <div className={cn("relative", mobile && "h-full")}>
aria-label="运行时间轴" <TimelineLegend
className={cn( mobile={mobile}
"flex min-h-0 flex-col overflow-hidden text-slate-900", visibility={eventVisibility}
mobile onVisibilityChange={setEventVisibility}
? "surface-reading h-full rounded-t-xl border-t" />
: "acrylic-panel rounded-2xl border" <motion.section
)} aria-label="运行时间轴"
initial={false}
animate={{ height }}
transition={sizeTransition}
>
<div
className={cn( className={cn(
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3", "flex min-h-0 flex-col overflow-hidden text-slate-900",
mode === "compact" && !mobile ? "h-10" : "h-12" mobile
? "surface-reading h-full rounded-t-xl border-t"
: "acrylic-panel rounded-2xl border"
)} )}
initial={false}
animate={{ height }}
transition={sizeTransition}
> >
<div className="flex min-w-0 items-center gap-2"> <div
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" /> className={cn(
<div className="flex items-center gap-2"> "flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}></p> mode === "compact" && !mobile ? "h-10" : "h-12"
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p> )}
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} />
{selectedEvent && !mobile ? (
<button
type="button"
onClick={onClearSelection}
title="清除当前事件选择"
className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
>
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
<AnimatePresence initial={false} mode="wait">
{mode === "expanded" || mobile ? (
<motion.div
key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
> >
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500"> <div className="flex min-w-0 items-center gap-2">
<span /> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
{LANES.map((lane) => ( <div className="flex items-center gap-2">
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span> <p
))} className={cn(
</div> "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2"> ((mode === "compact" && !mobile) || mobile) && "hidden"
<TimeTicks /> )}
{LANES.map((lane) => ( >
<TimelineLane
key={lane.id} </p>
lane={lane.id} <p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
style={{ left: `${cursorPosition}%` }}
>
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
</div> </div>
</div> </div>
</motion.div> <span className="hidden h-5 w-px bg-slate-300 sm:block" />
) : ( <TimelineSummary summary={summary} />
<motion.div {selectedEvent && !mobile ? (
key="compact-timeline" <button
className="relative min-h-0 flex-1 px-3" type="button"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }} onClick={onClearSelection}
animate={{ opacity: 1, y: 0 }} title="清除当前事件选择"
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }} className="min-w-0 truncate text-left text-[11px] text-slate-500 hover:text-blue-700"
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }} >
> {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
<CompactTimeTicks /> </button>
<div className="absolute bottom-2 left-3 right-3 top-4"> ) : null}
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" /> <TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
{clusters.map((cluster, index) => ( </div>
<TimelineMarker
key={cluster.id} <AnimatePresence initial={false} mode="wait">
cluster={cluster} {mode === "expanded" || mobile ? (
compact <motion.div
markerIndex={index} key="expanded-timeline"
selectedEventId={selectedEventId} className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]"
onSelectEvent={onSelectEvent} initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
/> animate={{ opacity: 1, y: 0 }}
))} exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
<div transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500" >
style={{ left: `${cursorPosition}%` }} <div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
/> <span />
</div> {LANES.map((lane) => (
</motion.div> <span key={lane.id} className="flex items-center font-semibold">
)} {lane.label}
</AnimatePresence> </span>
</motion.section> ))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
<TimeTicks />
{LANES.map((lane) => (
<TimelineLane
key={lane.id}
lane={lane.id}
clusters={clusters.filter((cluster) => cluster.lane === lane.id)}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-[18px] z-10 w-px bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.45)]"
style={{ left: `${cursorPosition}%` }}
>
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
</div>
</div>
</motion.div>
) : (
<motion.div
key="compact-timeline"
className="relative min-h-0 flex-1 px-3"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<CompactTimeTicks />
<div className="absolute bottom-2 left-3 right-3 top-4">
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
{clusters.map((cluster, index) => (
<TimelineMarker
key={cluster.id}
cluster={cluster}
compact
markerIndex={index}
selectedEventId={selectedEventId}
onSelectEvent={onSelectEvent}
/>
))}
<div
className="pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-blue-500"
style={{ left: `${cursorPosition}%` }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.section>
</div>
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -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)]", "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" && event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]", "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)]", "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" && event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]", "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} ) : null}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl"> <TooltipContent
<div className="flex items-center gap-2"> side="top"
<span className={cn( sideOffset={10}
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold", className={cn(
event.lane === "plan" && "bg-blue-50 text-blue-700", "w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border bg-[rgba(248,251,255,0.94)] p-0 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.2)] backdrop-blur-xl",
event.lane === "actual" && "bg-emerald-50 text-emerald-700", event.lane === "plan" && "border-blue-200/90",
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800", event.lane === "actual" && "border-emerald-200/90",
event.status === "failed" && "bg-rose-50 text-rose-700" event.lane === "exception" && event.status !== "failed" && "border-amber-200/90",
)}> event.status === "failed" && "border-rose-200/90"
{operationalEventNatureLabel(event)} )}
</span> >
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span> <div className="flex">
<span
className={cn(
"w-1 shrink-0",
event.lane === "plan" && "bg-blue-500",
event.lane === "actual" && "bg-emerald-500",
event.lane === "exception" && event.status !== "failed" && "bg-amber-500",
event.status === "failed" && "bg-rose-500"
)}
/>
<div className="min-w-0 flex-1 p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)}
</span>
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
<div className="mt-2.5 flex items-center justify-between gap-3 border-t border-slate-200/80 pt-2">
<span className="text-[10px] text-slate-500">
{cluster.events.length > 1 ? `同窗 ${cluster.events.length}` : "单项事件"}
</span>
{event.conditionId ? (
<span className="text-[11px] font-semibold text-blue-700"> </span>
) : (
<span className="text-[10px] text-slate-400">
{event.correlationId ?? event.scheduleId}
</span>
)}
</div>
</div>
</div> </div>
<p className="mt-1.5 font-semibold">{formatClock(event.cursorTime)} · {event.title}</p>
<p className="mt-1 leading-5 text-slate-600">{event.description}</p>
{cluster.events.length > 1 ? <p className="mt-1 text-slate-500"> {cluster.events.length} </p> : null}
{event.conditionId ? <p className="mt-1 font-medium text-blue-700"></p> : null}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -324,9 +410,15 @@ function operationalEventNatureLabel(event: OperationalEvent) {
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) { function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return ( return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3"> <div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span> <strong className="text-blue-700">{summary.planned}</strong></span> <span>
<span> <strong className="text-emerald-700">{summary.active}</strong></span> <strong className="text-blue-700">{summary.planned}</strong>
<span> <strong className="text-amber-700">{summary.exceptions}</strong></span> </span>
<span>
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.exceptions}</strong>
</span>
</div> </div>
); );
} }
@@ -375,6 +467,71 @@ function TimelineActions({
); );
} }
function TimelineLegend({
mobile,
visibility,
onVisibilityChange
}: {
mobile: boolean;
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className={cn(
"surface-control absolute z-40 flex items-center gap-1 rounded-xl border border-white/70 p-1 shadow-[0_10px_28px_rgba(15,23,42,0.14)]",
mobile ? "right-3 top-14" : "-top-11 right-2"
)}
>
<span className="px-1.5 text-[9px] font-semibold tracking-[0.12em] text-slate-500"></span>
{LANES.map((lane) => {
const visible = visibility[lane.id];
return (
<button
key={lane.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[lane.id]: !visible
})
}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium transition-[color,background-color,opacity] hover:bg-white/80",
visible ? "text-slate-700" : "text-slate-400 opacity-55"
)}
>
<LegendSwatch lane={lane.id} />
<span>{lane.label}</span>
{visible ? (
<Eye size={12} className="text-slate-400" aria-hidden="true" />
) : (
<EyeOff size={12} className="text-slate-400" aria-hidden="true" />
)}
</button>
);
})}
</div>
);
}
function LegendSwatch({ lane }: { lane: OperationalEventLane }) {
return (
<span
className={cn(
"block h-3 w-3 shrink-0 border",
lane === "plan" && "rounded-[4px] border-blue-400 bg-blue-50",
lane === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
lane === "exception" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100"
)}
/>
);
}
function TimeTicks() { function TimeTicks() {
return ( return (
<div className="relative text-[9px] text-slate-600"> <div className="relative text-[9px] text-slate-600">
@@ -414,7 +571,9 @@ function CompactTimeTicks() {
} }
function pickClusterEvent(events: OperationalEvent[]) { 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"]) { function importanceRank(value: OperationalEvent["importance"]) {
@@ -428,7 +587,11 @@ function getDayPosition(value: string) {
} }
function formatClock(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) { function formatDate(value: string) {
@@ -17,7 +17,7 @@ export function ArtifactPeekBar({
<aside <aside
aria-label="Agent 成果预览" aria-label="Agent 成果预览"
className={cn( className={cn(
"acrylic-panel pointer-events-auto absolute top-28 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900", "acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2", "left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]" surfaceMode === "map_split" && "2xl:left-[25%]"
)} )}