fix: distinguish timeline attention events
This commit is contained in:
@@ -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,13 +115,7 @@ export function OperationalTimeline({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div className={cn("relative", mobile && "h-full")}>
|
||||
<TimelineLegend
|
||||
mobile={mobile}
|
||||
visibility={eventVisibility}
|
||||
onVisibilityChange={setEventVisibility}
|
||||
/>
|
||||
<motion.section
|
||||
<motion.section
|
||||
aria-label="运行时间轴"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||
@@ -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>
|
||||
|
||||
@@ -223,8 +233,7 @@ export function OperationalTimeline({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.section>
|
||||
</div>
|
||||
</motion.section>
|
||||
</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,20 +363,10 @@ 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>
|
||||
{cluster.events.length > 1 ? (
|
||||
<p className="mt-2 text-[10px] text-slate-500">同一时间窗 {cluster.events.length} 项</p>
|
||||
) : null}
|
||||
</div>
|
||||
</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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user