fix: distinguish timeline attention events

This commit is contained in:
2026-08-19 17:12:13 +08:00
parent 602b39d60a
commit b91be09f5c
4 changed files with 88 additions and 69 deletions
@@ -996,7 +996,8 @@ export function MapWorkbenchPage({
<span className="font-semibold"></span>
<span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.exceptions}</span>
<span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="text-rose-700"> {operationalSummary.exceptions}</span>
<span className="ml-auto text-slate-500"></span>
</button>
</> : null
@@ -6,9 +6,11 @@ import {
createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout
} from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1",
@@ -52,6 +54,28 @@ describe("operational timeline model", () => {
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal",
@@ -9,7 +9,8 @@ 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<OperationalEventLane, boolean>;
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = {
id: string;
@@ -50,6 +51,7 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
@@ -128,16 +130,24 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
exceptions: events.filter((event) => event.lane === "exception").length,
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[event.lane]);
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
@@ -21,6 +21,7 @@ import {
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalEventVisibility,
@@ -46,6 +47,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "exception", label: "异常" }
];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
@@ -96,6 +104,10 @@ export function OperationalTimeline({
) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span>
)}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section>
);
@@ -103,12 +115,6 @@ export function OperationalTimeline({
return (
<TooltipProvider delayDuration={180}>
<div className={cn("relative", mobile && "h-full")}>
<TimelineLegend
mobile={mobile}
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<motion.section
aria-label="运行时间轴"
className={cn(
@@ -142,7 +148,7 @@ export function OperationalTimeline({
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} />
{!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? (
<button
type="button"
@@ -153,6 +159,10 @@ export function OperationalTimeline({
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
@@ -224,7 +234,6 @@ export function OperationalTimeline({
)}
</AnimatePresence>
</motion.section>
</div>
</TooltipProvider>
);
}
@@ -310,24 +319,10 @@ function TimelineMarker({
side="top"
sideOffset={10}
className={cn(
"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 === "plan" && "border-blue-200/90",
event.lane === "actual" && "border-emerald-200/90",
event.lane === "exception" && event.status !== "failed" && "border-amber-200/90",
event.status === "failed" && "border-rose-200/90"
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
)}
>
<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="surface-reading min-w-0 rounded-[13px] border p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
@@ -368,19 +363,9 @@ function TimelineMarker({
<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>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500"> {cluster.events.length} </p>
) : null}
</div>
</TooltipContent>
</Tooltip>
@@ -401,8 +386,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "关键异常";
if (event.lane === "exception") return "运行偏差";
if (event.status === "failed") return "异常";
if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
@@ -417,7 +402,10 @@ function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationa
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.exceptions}</strong>
<strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
<strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div>
);
@@ -433,7 +421,7 @@ function TimelineActions({
onReturnNow: () => void;
}) {
return (
<div className="surface-control ml-auto flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button
type="button"
title="返回现在"
@@ -468,49 +456,44 @@ 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"
)}
className="surface-control ml-auto flex shrink-0 items-center gap-0.5 rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]"
>
<span className="px-1.5 text-[9px] font-semibold tracking-[0.12em] text-slate-500"></span>
{LANES.map((lane) => {
const visible = visibility[lane.id];
<span className="hidden px-1.5 text-[9px] font-semibold tracking-[0.12em] text-slate-500 xl:inline"></span>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={lane.id}
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[lane.id]: !visible
[item.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",
"inline-flex h-7 items-center gap-1.5 rounded-lg px-1.5 text-[10px] font-medium transition-[color,background-color,opacity] hover:bg-white/80 sm:px-2",
visible ? "text-slate-700" : "text-slate-400 opacity-55"
)}
>
<LegendSwatch lane={lane.id} />
<span>{lane.label}</span>
<LegendSwatch category={item.id} />
<span className="hidden sm:inline">{item.label}</span>
{visible ? (
<Eye size={12} className="text-slate-400" aria-hidden="true" />
<Eye size={12} className="hidden text-slate-400 sm:block" aria-hidden="true" />
) : (
<EyeOff size={12} className="text-slate-400" aria-hidden="true" />
<EyeOff size={12} className="hidden text-slate-400 sm:block" aria-hidden="true" />
)}
</button>
);
@@ -519,14 +502,15 @@ function TimelineLegend({
);
}
function LegendSwatch({ lane }: { lane: OperationalEventLane }) {
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
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"
category === "plan" && "rounded-[4px] border-blue-400 bg-blue-50",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);