feat: add interactive timeline legend
This commit is contained in:
@@ -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,11 +65,10 @@ 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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
.flatMap((item) =>
|
||||||
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||||
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
)
|
||||||
|
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clusterOperationalEvents(
|
export function clusterOperationalEvents(
|
||||||
@@ -121,12 +133,20 @@ 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}`,
|
id: `exception-${item.id}`,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.report?.conclusion ?? item.summary,
|
description: item.report?.conclusion ?? item.summary,
|
||||||
@@ -141,7 +161,8 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
|
|||||||
conditionId: item.id,
|
conditionId: item.id,
|
||||||
sourceLabel: "工况诊断",
|
sourceLabel: "工况诊断",
|
||||||
mapTargets: getConditionMapTargets(item)
|
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,6 +103,12 @@ export function OperationalTimeline({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180}>
|
<TooltipProvider delayDuration={180}>
|
||||||
|
<div className={cn("relative", mobile && "h-full")}>
|
||||||
|
<TimelineLegend
|
||||||
|
mobile={mobile}
|
||||||
|
visibility={eventVisibility}
|
||||||
|
onVisibilityChange={setEventVisibility}
|
||||||
|
/>
|
||||||
<motion.section
|
<motion.section
|
||||||
aria-label="运行时间轴"
|
aria-label="运行时间轴"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -114,7 +130,14 @@ export function OperationalTimeline({
|
|||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}>运行时间轴</p>
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
|
||||||
|
((mode === "compact" && !mobile) || mobile) && "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
运行时间轴
|
||||||
|
</p>
|
||||||
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +169,9 @@ export function OperationalTimeline({
|
|||||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
||||||
<span />
|
<span />
|
||||||
{LANES.map((lane) => (
|
{LANES.map((lane) => (
|
||||||
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
|
<span key={lane.id} className="flex items-center font-semibold">
|
||||||
|
{lane.label}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
|
||||||
@@ -199,6 +224,7 @@ export function OperationalTimeline({
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.section>
|
</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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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)}
|
{operationalEventNatureLabel(event)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-500">{event.sourceLabel}</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%]"
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user