feat: add operational timeline workspace
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import { clusterOperationalEvents, createOperationalEvents } from "./operational-timeline-model";
|
||||
|
||||
const workOrder: ScheduledConditionItem = {
|
||||
id: "work-1", kind: "work_order", code: "WO-1", 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", () => {
|
||||
it("creates linked plan and actual events for active work orders", () => {
|
||||
const events = createOperationalEvents([workOrder]);
|
||||
expect(events.map((event) => event.lane)).toEqual(["plan", "actual"]);
|
||||
expect(events[0].correlationId).toBe(events[1].correlationId);
|
||||
});
|
||||
|
||||
it("only promotes abnormal condition runs into exception events", () => {
|
||||
const normal = {
|
||||
id: "condition-normal", kind: "condition", 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;
|
||||
|
||||
expect(createOperationalEvents([normal])).toHaveLength(0);
|
||||
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
|
||||
});
|
||||
|
||||
it("aggregates nearby events into ten minute lane buckets", () => {
|
||||
const events = createOperationalEvents([workOrder]);
|
||||
const nearbyPlan = {
|
||||
...events[0],
|
||||
id: "plan-nearby",
|
||||
cursorTime: "2026-08-19T10:05:00.000Z"
|
||||
};
|
||||
const clusters = clusterOperationalEvents([...events, nearbyPlan], 10);
|
||||
expect(clusters).toHaveLength(2);
|
||||
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { FeatureTarget } from "../map/workbench-map-controller";
|
||||
import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
|
||||
|
||||
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 OperationalEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
lane: OperationalEventLane;
|
||||
status: OperationalEventStatus;
|
||||
importance: OperationalEventImportance;
|
||||
plannedTime: string | null;
|
||||
actualTime: string | null;
|
||||
cursorTime: string;
|
||||
correlationId: string | null;
|
||||
scheduleId: string;
|
||||
conditionId: string | null;
|
||||
sourceLabel: string;
|
||||
mapTargets: FeatureTarget[];
|
||||
};
|
||||
|
||||
export type OperationalEventCluster = {
|
||||
id: string;
|
||||
lane: OperationalEventLane;
|
||||
bucketStart: number;
|
||||
events: OperationalEvent[];
|
||||
};
|
||||
|
||||
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
||||
return items.flatMap((item) =>
|
||||
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
|
||||
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
|
||||
}
|
||||
|
||||
export function clusterOperationalEvents(
|
||||
events: OperationalEvent[],
|
||||
bucketMinutes = 10
|
||||
): OperationalEventCluster[] {
|
||||
const bucketMs = bucketMinutes * 60_000;
|
||||
const clusters = new Map<string, OperationalEventCluster>();
|
||||
|
||||
events.forEach((event) => {
|
||||
const timestamp = Date.parse(event.cursorTime);
|
||||
const bucketStart = Math.floor(timestamp / bucketMs) * bucketMs;
|
||||
const key = `${event.lane}-${bucketStart}`;
|
||||
const cluster = clusters.get(key) ?? {
|
||||
id: key,
|
||||
lane: event.lane,
|
||||
bucketStart,
|
||||
events: []
|
||||
};
|
||||
cluster.events.push(event);
|
||||
clusters.set(key, cluster);
|
||||
});
|
||||
|
||||
return [...clusters.values()].sort((left, right) => left.bucketStart - right.bucketStart);
|
||||
}
|
||||
|
||||
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,
|
||||
critical: events.filter((event) => event.importance === "critical").length
|
||||
};
|
||||
}
|
||||
|
||||
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
|
||||
if (item.status !== "warning" && item.status !== "error") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
id: `exception-${item.id}`,
|
||||
title: item.title,
|
||||
description: item.report?.conclusion ?? item.summary,
|
||||
lane: "exception",
|
||||
status: item.status === "error" ? "failed" : "delayed",
|
||||
importance: item.status === "error" ? "critical" : "important",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: item.scheduledAt,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.sessionId,
|
||||
scheduleId: item.taskId,
|
||||
conditionId: item.id,
|
||||
sourceLabel: "工况诊断",
|
||||
mapTargets: getConditionMapTargets(item)
|
||||
}];
|
||||
}
|
||||
|
||||
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
|
||||
const planEvent: OperationalEvent = {
|
||||
id: `plan-${item.id}`,
|
||||
title: item.title,
|
||||
description: `${item.location} · ${item.assignee}`,
|
||||
lane: "plan",
|
||||
status: "planned",
|
||||
importance: item.priority === "urgent" ? "important" : "normal",
|
||||
plannedTime: item.scheduledAt,
|
||||
actualTime: null,
|
||||
cursorTime: item.scheduledAt,
|
||||
correlationId: item.code,
|
||||
scheduleId: item.id,
|
||||
conditionId: item.id,
|
||||
sourceLabel: item.source,
|
||||
mapTargets: getWorkOrderMapTargets(item)
|
||||
};
|
||||
|
||||
if (item.status === "pending") {
|
||||
return [planEvent];
|
||||
}
|
||||
|
||||
const start = Date.parse(item.scheduledAt);
|
||||
const actualTime = new Date(
|
||||
item.status === "running" ? start + 3 * 60_000 : start + (item.durationMinutes ?? 0) * 60_000
|
||||
).toISOString();
|
||||
const actualEvent: OperationalEvent = {
|
||||
...planEvent,
|
||||
id: `actual-${item.id}`,
|
||||
lane: "actual",
|
||||
status: "executed",
|
||||
actualTime,
|
||||
cursorTime: actualTime,
|
||||
description:
|
||||
item.status === "running"
|
||||
? `${item.assignee}正在执行,位置:${item.location}`
|
||||
: `${item.assignee}已完成现场执行,等待调度复令`
|
||||
};
|
||||
|
||||
return [planEvent, actualEvent];
|
||||
}
|
||||
|
||||
function getConditionMapTargets(item: ScheduledConditionRecord): FeatureTarget[] {
|
||||
if (item.taskId === "scada-diagnosis") {
|
||||
return [{ sourceId: "scada", featureId: "SCADA-07" }];
|
||||
}
|
||||
if (item.taskId === "pump-energy") {
|
||||
return [{ sourceId: "junctions", featureId: "PUMP-02" }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getWorkOrderMapTargets(item: ScheduledWorkOrderItem): FeatureTarget[] {
|
||||
if (item.location.includes("阀")) {
|
||||
return [{ sourceId: "valves", featureId: extractAssetId(item.location) }];
|
||||
}
|
||||
if (item.location.includes("流量计")) {
|
||||
return [{ sourceId: "scada", featureId: extractAssetId(item.location) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractAssetId(location: string) {
|
||||
return location.match(/[A-Z]{1,3}-\d{1,3}/)?.[0] ?? location;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock3,
|
||||
OctagonAlert,
|
||||
RotateCcw,
|
||||
TriangleAlert
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
clusterOperationalEvents,
|
||||
getOperationalEventSummary,
|
||||
type OperationalEvent,
|
||||
type OperationalEventCluster,
|
||||
type OperationalEventLane,
|
||||
type OperationalTimelineMode
|
||||
} from "./operational-timeline-model";
|
||||
|
||||
type OperationalTimelineProps = {
|
||||
date: string;
|
||||
cursorTime: string;
|
||||
events: OperationalEvent[];
|
||||
mode: OperationalTimelineMode;
|
||||
selectedEventId: string | null;
|
||||
mobile?: boolean;
|
||||
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
onClearSelection: () => void;
|
||||
onReturnNow: () => void;
|
||||
};
|
||||
|
||||
const LANES: Array<{ id: OperationalEventLane; label: string }> = [
|
||||
{ id: "plan", label: "计划" },
|
||||
{ id: "actual", label: "实际" },
|
||||
{ id: "exception", label: "异常" }
|
||||
];
|
||||
|
||||
export function OperationalTimeline({
|
||||
date,
|
||||
cursorTime,
|
||||
events,
|
||||
mode,
|
||||
selectedEventId,
|
||||
mobile = false,
|
||||
onModeChange,
|
||||
onSelectEvent,
|
||||
onClearSelection,
|
||||
onReturnNow
|
||||
}: OperationalTimelineProps) {
|
||||
const summary = getOperationalEventSummary(events);
|
||||
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
|
||||
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
||||
const cursorPosition = getDayPosition(cursorTime);
|
||||
const height = mobile ? "100%" : mode === "expanded" ? 240 : mode === "summary" ? 52 : 72;
|
||||
|
||||
if (mode === "summary" && !mobile) {
|
||||
return (
|
||||
<section
|
||||
aria-label="运行时间轴摘要"
|
||||
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
||||
style={{ height }}
|
||||
>
|
||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
||||
<span className="h-4 w-px bg-slate-300" />
|
||||
<TimelineSummary summary={summary} />
|
||||
{selectedEvent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
className="min-w-0 truncate text-left text-xs text-slate-600 hover:text-blue-700"
|
||||
>
|
||||
已选 {formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
||||
)}
|
||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<section
|
||||
aria-label="运行时间轴"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||
mobile
|
||||
? "surface-reading h-full rounded-t-xl border-t"
|
||||
: "acrylic-panel rounded-2xl border"
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||
mode === "compact" && !mobile ? "h-8" : "h-12"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
|
||||
<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="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>
|
||||
|
||||
{mode === "expanded" || mobile ? (
|
||||
<div 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)]">
|
||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
||||
<span />
|
||||
{LANES.map((lane) => (
|
||||
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1 px-3">
|
||||
<CompactTimeTicks />
|
||||
<div className="absolute bottom-1 left-3 right-3 top-3.5">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineLane({
|
||||
lane,
|
||||
clusters,
|
||||
selectedEventId,
|
||||
onSelectEvent
|
||||
}: {
|
||||
lane: OperationalEventLane;
|
||||
clusters: OperationalEventCluster[];
|
||||
selectedEventId: string | null;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative border-t border-slate-200" data-lane={lane}>
|
||||
{clusters.map((cluster, index) => (
|
||||
<TimelineMarker
|
||||
key={cluster.id}
|
||||
cluster={cluster}
|
||||
markerIndex={index}
|
||||
selectedEventId={selectedEventId}
|
||||
onSelectEvent={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMarker({
|
||||
cluster,
|
||||
compact = false,
|
||||
markerIndex,
|
||||
selectedEventId,
|
||||
onSelectEvent
|
||||
}: {
|
||||
cluster: OperationalEventCluster;
|
||||
compact?: boolean;
|
||||
markerIndex: number;
|
||||
selectedEventId: string | null;
|
||||
onSelectEvent: (event: OperationalEvent) => void;
|
||||
}) {
|
||||
const event = pickClusterEvent(cluster.events);
|
||||
const selected = cluster.events.some((item) => item.id === selectedEventId);
|
||||
const position = getDayPosition(event.cursorTime);
|
||||
const exception = event.lane === "exception";
|
||||
const verticalPosition = compact ? 56 : [24, 50, 76][markerIndex % 3];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
|
||||
onClick={() => onSelectEvent(event)}
|
||||
className={cn(
|
||||
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600",
|
||||
compact ? "h-2 w-2" : "h-5 w-5",
|
||||
event.lane === "plan" &&
|
||||
"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" &&
|
||||
"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" &&
|
||||
"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" &&
|
||||
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
|
||||
event.importance === "critical" && !compact && "h-6 w-6",
|
||||
selected && "ring-2 ring-blue-600 ring-offset-2 ring-offset-white"
|
||||
)}
|
||||
style={{ left: `${position}%`, top: `${verticalPosition}%` }}
|
||||
>
|
||||
{!compact ? <OperationalEventGlyph event={event} /> : null}
|
||||
{cluster.events.length > 1 && !compact ? (
|
||||
<span className="absolute -right-2 -top-2 min-w-3.5 rounded-full bg-white px-1 text-[8px] font-bold text-slate-950">
|
||||
{cluster.events.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
|
||||
event.lane === "plan" && "bg-blue-50 text-blue-700",
|
||||
event.lane === "actual" && "bg-emerald-50 text-emerald-700",
|
||||
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800",
|
||||
event.status === "failed" && "bg-rose-50 text-rose-700"
|
||||
)}>
|
||||
{operationalEventNatureLabel(event)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span>
|
||||
</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>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
|
||||
if (event.status === "failed") {
|
||||
return <OctagonAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||
}
|
||||
if (event.lane === "exception") {
|
||||
return <TriangleAlert size={12} strokeWidth={2.4} aria-hidden="true" />;
|
||||
}
|
||||
if (event.lane === "actual") {
|
||||
return <Check size={12} strokeWidth={2.8} aria-hidden="true" />;
|
||||
}
|
||||
return <CalendarClock size={11} strokeWidth={2.2} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function operationalEventNatureLabel(event: OperationalEvent) {
|
||||
if (event.status === "failed") return "关键异常";
|
||||
if (event.lane === "exception") return "运行偏差";
|
||||
if (event.lane === "actual") return "实际执行";
|
||||
return "计划任务";
|
||||
}
|
||||
|
||||
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
|
||||
return (
|
||||
<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>执行 <strong className="text-emerald-700">{summary.active}</strong></span>
|
||||
<span>异常 <strong className="text-amber-700">{summary.exceptions}</strong></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineActions({
|
||||
mode,
|
||||
onModeChange,
|
||||
onReturnNow
|
||||
}: {
|
||||
mode: OperationalTimelineMode;
|
||||
onModeChange?: (mode: OperationalTimelineMode) => void;
|
||||
onReturnNow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title="返回现在"
|
||||
aria-label="返回现在"
|
||||
onClick={onReturnNow}
|
||||
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{onModeChange ? (
|
||||
<button
|
||||
type="button"
|
||||
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
||||
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
|
||||
>
|
||||
{mode === "expanded" ? <ChevronDown size={15} /> : <ChevronUp size={15} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeTicks() {
|
||||
return (
|
||||
<div className="relative text-[9px] text-slate-600">
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span
|
||||
key={hour}
|
||||
className={cn(
|
||||
"absolute",
|
||||
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
)}
|
||||
style={{ left: `${(hour / 24) * 100}%` }}
|
||||
>
|
||||
{String(hour).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactTimeTicks() {
|
||||
return (
|
||||
<div className="absolute inset-x-3 top-0 text-[8px] text-slate-500">
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span
|
||||
key={hour}
|
||||
className={cn(
|
||||
"absolute",
|
||||
hour === 0 ? "translate-x-0" : hour === 24 ? "-translate-x-full" : "-translate-x-1/2"
|
||||
)}
|
||||
style={{ left: `${(hour / 24) * 100}%` }}
|
||||
>
|
||||
{String(hour).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickClusterEvent(events: OperationalEvent[]) {
|
||||
return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0];
|
||||
}
|
||||
|
||||
function importanceRank(value: OperationalEvent["importance"]) {
|
||||
return value === "critical" ? 2 : value === "important" ? 1 : 0;
|
||||
}
|
||||
|
||||
function getDayPosition(value: string) {
|
||||
const date = new Date(value);
|
||||
const minutes = date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
|
||||
return Math.min(100, Math.max(0, (minutes / (24 * 60)) * 100));
|
||||
}
|
||||
|
||||
function formatClock(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const [year, month, day] = value.split("-");
|
||||
return `${year}.${month}.${day}`;
|
||||
}
|
||||
Reference in New Issue
Block a user