fix: distinguish timeline attention events
This commit is contained in:
@@ -996,7 +996,8 @@ export function MapWorkbenchPage({
|
|||||||
<span className="font-semibold">运行时间轴</span>
|
<span className="font-semibold">运行时间轴</span>
|
||||||
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
|
||||||
<span className="text-slate-500">执行 {operationalSummary.active}</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>
|
<span className="ml-auto text-slate-500">查看全天</span>
|
||||||
</button>
|
</button>
|
||||||
</> : null
|
</> : null
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
createOperationalEvents,
|
createOperationalEvents,
|
||||||
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
|
||||||
filterOperationalEvents,
|
filterOperationalEvents,
|
||||||
|
getOperationalEventCategory,
|
||||||
OPERATIONAL_TIMELINE_LAYOUT,
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
resolveTimelineScaleLayout
|
resolveTimelineScaleLayout
|
||||||
} from "./operational-timeline-model";
|
} from "./operational-timeline-model";
|
||||||
|
import type { OperationalEvent } from "./operational-timeline-model";
|
||||||
|
|
||||||
const workOrder: ScheduledConditionItem = {
|
const workOrder: ScheduledConditionItem = {
|
||||||
id: "work-1",
|
id: "work-1",
|
||||||
@@ -52,6 +54,28 @@ describe("operational timeline model", () => {
|
|||||||
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
|
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", () => {
|
it("only promotes abnormal condition runs into exception events", () => {
|
||||||
const normal = {
|
const normal = {
|
||||||
id: "condition-normal",
|
id: "condition-normal",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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 OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
|
||||||
|
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
|
||||||
|
|
||||||
export type OperationalEvent = {
|
export type OperationalEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,6 +51,7 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
|
|||||||
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
|
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
|
||||||
plan: true,
|
plan: true,
|
||||||
actual: true,
|
actual: true,
|
||||||
|
attention: true,
|
||||||
exception: true
|
exception: true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,16 +130,24 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
|
|||||||
return {
|
return {
|
||||||
planned: events.filter((event) => event.lane === "plan").length,
|
planned: events.filter((event) => event.lane === "plan").length,
|
||||||
active: events.filter((event) => event.lane === "actual").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
|
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(
|
export function filterOperationalEvents(
|
||||||
events: OperationalEvent[],
|
events: OperationalEvent[],
|
||||||
visibility: OperationalEventVisibility
|
visibility: OperationalEventVisibility
|
||||||
) {
|
) {
|
||||||
return events.filter((event) => visibility[event.lane]);
|
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
|
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getOperationalEventSummary,
|
getOperationalEventSummary,
|
||||||
OPERATIONAL_TIMELINE_LAYOUT,
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
type OperationalEvent,
|
type OperationalEvent,
|
||||||
|
type OperationalEventCategory,
|
||||||
type OperationalEventCluster,
|
type OperationalEventCluster,
|
||||||
type OperationalEventLane,
|
type OperationalEventLane,
|
||||||
type OperationalEventVisibility,
|
type OperationalEventVisibility,
|
||||||
@@ -46,6 +47,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
|||||||
{ id: "exception", label: "异常" }
|
{ 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({
|
export function OperationalTimeline({
|
||||||
date,
|
date,
|
||||||
cursorTime,
|
cursorTime,
|
||||||
@@ -96,6 +104,10 @@ export function OperationalTimeline({
|
|||||||
) : (
|
) : (
|
||||||
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
||||||
)}
|
)}
|
||||||
|
<TimelineLegend
|
||||||
|
visibility={eventVisibility}
|
||||||
|
onVisibilityChange={setEventVisibility}
|
||||||
|
/>
|
||||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
</motion.section>
|
</motion.section>
|
||||||
);
|
);
|
||||||
@@ -103,13 +115,7 @@ export function OperationalTimeline({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180}>
|
<TooltipProvider delayDuration={180}>
|
||||||
<div className={cn("relative", mobile && "h-full")}>
|
<motion.section
|
||||||
<TimelineLegend
|
|
||||||
mobile={mobile}
|
|
||||||
visibility={eventVisibility}
|
|
||||||
onVisibilityChange={setEventVisibility}
|
|
||||||
/>
|
|
||||||
<motion.section
|
|
||||||
aria-label="运行时间轴"
|
aria-label="运行时间轴"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||||
@@ -142,7 +148,7 @@ export function OperationalTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
|
||||||
<TimelineSummary summary={summary} />
|
{!mobile ? <TimelineSummary summary={summary} /> : null}
|
||||||
{selectedEvent && !mobile ? (
|
{selectedEvent && !mobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,6 +159,10 @@ export function OperationalTimeline({
|
|||||||
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TimelineLegend
|
||||||
|
visibility={eventVisibility}
|
||||||
|
onVisibilityChange={setEventVisibility}
|
||||||
|
/>
|
||||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,8 +233,7 @@ export function OperationalTimeline({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,24 +319,10 @@ function TimelineMarker({
|
|||||||
side="top"
|
side="top"
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
className={cn(
|
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",
|
"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)]"
|
||||||
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"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex">
|
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
|
||||||
<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">
|
<div className="flex items-start gap-2.5">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -368,20 +363,10 @@ function TimelineMarker({
|
|||||||
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
|
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
|
||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2.5 flex items-center justify-between gap-3 border-t border-slate-200/80 pt-2">
|
{cluster.events.length > 1 ? (
|
||||||
<span className="text-[10px] text-slate-500">
|
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
|
||||||
{cluster.events.length > 1 ? `同窗 ${cluster.events.length} 项` : "单项事件"}
|
) : null}
|
||||||
</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>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -401,8 +386,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function operationalEventNatureLabel(event: OperationalEvent) {
|
function operationalEventNatureLabel(event: OperationalEvent) {
|
||||||
if (event.status === "failed") return "关键异常";
|
if (event.status === "failed") return "异常";
|
||||||
if (event.lane === "exception") return "运行偏差";
|
if (event.lane === "exception") return "关注";
|
||||||
if (event.lane === "actual") return "实际执行";
|
if (event.lane === "actual") return "实际执行";
|
||||||
return "计划任务";
|
return "计划任务";
|
||||||
}
|
}
|
||||||
@@ -417,7 +402,10 @@ function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationa
|
|||||||
执行 <strong className="text-emerald-700">{summary.active}</strong>
|
执行 <strong className="text-emerald-700">{summary.active}</strong>
|
||||||
</span>
|
</span>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -433,7 +421,7 @@ function TimelineActions({
|
|||||||
onReturnNow: () => void;
|
onReturnNow: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="返回现在"
|
title="返回现在"
|
||||||
@@ -468,49 +456,44 @@ function TimelineActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimelineLegend({
|
function TimelineLegend({
|
||||||
mobile,
|
|
||||||
visibility,
|
visibility,
|
||||||
onVisibilityChange
|
onVisibilityChange
|
||||||
}: {
|
}: {
|
||||||
mobile: boolean;
|
|
||||||
visibility: OperationalEventVisibility;
|
visibility: OperationalEventVisibility;
|
||||||
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
|
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label="时间轴图例"
|
aria-label="时间轴图例"
|
||||||
className={cn(
|
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)]"
|
||||||
"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>
|
<span className="hidden px-1.5 text-[9px] font-semibold tracking-[0.12em] text-slate-500 xl:inline">图例</span>
|
||||||
{LANES.map((lane) => {
|
{LEGEND_ITEMS.map((item) => {
|
||||||
const visible = visibility[lane.id];
|
const visible = visibility[item.id];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lane.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
|
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||||
aria-label={`${visible ? "隐藏" : "显示"}${lane.label}事件`}
|
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
|
||||||
aria-pressed={visible}
|
aria-pressed={visible}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onVisibilityChange({
|
onVisibilityChange({
|
||||||
...visibility,
|
...visibility,
|
||||||
[lane.id]: !visible
|
[item.id]: !visible
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
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"
|
visible ? "text-slate-700" : "text-slate-400 opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LegendSwatch lane={lane.id} />
|
<LegendSwatch category={item.id} />
|
||||||
<span>{lane.label}</span>
|
<span className="hidden sm:inline">{item.label}</span>
|
||||||
{visible ? (
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -519,14 +502,15 @@ function TimelineLegend({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegendSwatch({ lane }: { lane: OperationalEventLane }) {
|
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"block h-3 w-3 shrink-0 border",
|
"block h-3 w-3 shrink-0 border",
|
||||||
lane === "plan" && "rounded-[4px] border-blue-400 bg-blue-50",
|
category === "plan" && "rounded-[4px] border-blue-400 bg-blue-50",
|
||||||
lane === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
|
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
|
||||||
lane === "exception" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100"
|
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
|
||||||
|
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user