feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
+32
View File
@@ -0,0 +1,32 @@
let order: string[] = [];
const listeners = new Set<() => void>();
let host: HTMLDivElement | undefined;
export const floatingWindowOrder = () => order;
export const subscribeFloatingWindows = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export function raiseFloatingWindow(id: string) {
if (order.at(-1) === id) return;
order = [...order.filter((item) => item !== id), id];
listeners.forEach((listener) => listener());
}
export function removeFloatingWindow(id: string) {
if (!order.includes(id)) return;
order = order.filter((item) => item !== id);
listeners.forEach((listener) => listener());
}
export function floatingWindowHost() {
if (!host?.isConnected) {
host = document.createElement("div");
// One stacking context keeps windows below dialogs and newly opened menus.
host.className = "pointer-events-none fixed inset-0 z-50";
host.dataset.floatingWindowLayer = "";
document.body.appendChild(host);
}
return host;
}
+37
View File
@@ -0,0 +1,37 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { FloatingWindow } from "./floating-window";
afterEach(cleanup);
const content = () => <p>图表内容</p>;
it("opens display requests without stealing focus and respects dismissal until the next request", () => {
const { rerender } = render(<><button autoFocus>回放控制</button><FloatingWindow title="压力分析" openRequestKey="first">{content}</FloatingWindow></>);
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
expect(screen.getByRole("button", { name: "回放控制" })).toHaveFocus();
fireEvent.click(screen.getByRole("button", { name: "关闭窗口" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
rerender(<><button>回放控制</button><FloatingWindow title="压力分析" openRequestKey="first">{content}</FloatingWindow></>);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
rerender(<><button>回放控制</button><FloatingWindow title="压力分析" openRequestKey="second">{content}</FloatingWindow></>);
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
expect(screen.getByRole("button", { name: "回放控制" })).toHaveFocus();
fireEvent.click(screen.getByRole("button", { name: "关闭窗口" }));
rerender(<><button>回放控制</button><FloatingWindow title="压力分析" openRequestKey="first">{content}</FloatingWindow></>);
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
});
it("retains manual opening, Escape dismissal and focus return", () => {
render(<FloatingWindow title="压力分析">{content}</FloatingWindow>);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const trigger = screen.getByRole("button", { name: "放大查看 压力分析" });
fireEvent.click(trigger);
const heading = screen.getByLabelText("窗口标题栏,可用方向键移动,Shift 加方向键调整大小");
expect(heading).toHaveFocus();
fireEvent.keyDown(heading, { key: "Escape" });
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
fireEvent.click(trigger);
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
});
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button";
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
type Bounds = { x: number; y: number; width: number; height: number };
function fitWindow(bounds: Bounds): Bounds {
const width = Math.min(bounds.width, window.innerWidth - 16);
const height = Math.min(bounds.height, window.innerHeight - 16);
return { width, height,
x: Math.max(8, Math.min(bounds.x, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(bounds.y, window.innerHeight - height - 8)) };
}
export function FloatingWindow({ title, triggerLabel, fillContent = false, openRequestKey, children }: {
title: string;
triggerLabel?: string;
fillContent?: boolean;
/** A new display request opens the window without taking keyboard focus. */
openRequestKey?: string;
children: (size: { width: number; height: number }) => ReactNode;
}) {
const id = useId();
const titleId = useId();
const [manuallyOpen, setManuallyOpen] = useState(false);
const [request, setRequest] = useState({ key: openRequestKey, dismissed: false });
// A replay can return to an earlier request. Dismissal applies only until the
// displayed request changes, even when that next key was previously visited.
if (request.key !== openRequestKey) setRequest({ key: openRequestKey, dismissed: false });
const open = manuallyOpen || (openRequestKey !== undefined && !request.dismissed);
const [maximized, setMaximized] = useState(false);
const [bounds, setBounds] = useState<Bounds>(() => fitWindow({ x: (window.innerWidth - 800) / 2, y: 64, width: 800, height: 560 }));
const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight });
const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder);
const trigger = useRef<HTMLButtonElement>(null);
const heading = useRef<HTMLDivElement>(null);
const focusOnOpen = useRef(false);
const raise = () => raiseFloatingWindow(id);
const close = () => {
setManuallyOpen(false);
setRequest({ key: openRequestKey, dismissed: true });
removeFloatingWindow(id);
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
trigger.current?.focus({ preventScroll: true });
};
useEffect(() => () => removeFloatingWindow(id), [id]);
useEffect(() => {
if (!open) return;
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false;
const resize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds((current) => fitWindow(current));
};
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
removeFloatingWindow(id);
};
}, [id, open]);
const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
: bounds;
return <>
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
onClick={() => {
if (!open) {
const offset = (order.length % 5) * 24;
setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
setMaximized(false);
focusOnOpen.current = true;
setManuallyOpen(true);
} else heading.current?.focus({ preventScroll: true });
raise();
}}><Maximize2 size={15} aria-hidden /></Button>
{open ? createPortal(
<Rnd size={{ width: current.width, height: current.height }} position={{ x: current.x, y: current.y }}
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
disableDragging={maximized} enableResizing={!maximized}
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
resizeHandleStyles={{
top: { top: 0, height: 8 }, right: { right: 0, width: 8 },
bottom: { bottom: 0, height: 8 }, left: { left: 0, width: 8 },
topRight: { top: 0, right: 0, width: 18, height: 18 },
topLeft: { top: 0, left: 0, width: 18, height: 18 },
bottomLeft: { bottom: 0, left: 0, width: 18, height: 18 },
bottomRight: { right: 0, bottom: 0, width: 18, height: 18 }
}}
resizeHandleComponent={{ bottomRight: <span aria-hidden className="absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> }}
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window=""
onPointerDownCapture={raise} onFocusCapture={raise}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
}}>
<div ref={heading} tabIndex={0}
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
onKeyDown={(event) => {
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0;
setBounds((previous) => fitWindow(event.shiftKey
? { ...previous, width: Math.max(320, previous.width + dx), height: Math.max(260, previous.height + dy) }
: { ...previous, x: previous.x + dx, y: previous.y + dy }));
}}>
<h2 id={titleId} className="min-w-0 break-words text-sm font-semibold leading-5">{title}</h2>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />}
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
<X size={16} aria-hidden />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto overscroll-contain p-3" data-floating-window-content="">
<div className={fillContent ? "h-full" : undefined} data-floating-window-document="">
{children({ width: current.width - 26, height: current.height - 76 })}
</div>
</div>
</Rnd>, floatingWindowHost()) : null}
</>;
}
+43
View File
@@ -0,0 +1,43 @@
import { formatDateTime, formatTime } from "@/shared/utils/date-time";
const zonedTime = /(?<![\w/+:.-])(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)(Z|[+-]\d{2}:\d{2})(?![\w:+/-])/g;
const utcMarker = String.raw`(?:Z|(?:[((]\s*)?UTC(?:\s*\+?0(?::00)?)?(?:\s*[))])?)(?![\w:+/-])`;
const utcRange = new RegExp(String.raw`(?<![\w/+:.-])(?:(\d{4}-\d{2}-\d{2})[T ])?(\d{2}:\d{2})\s*[–—~至-]\s*(\d{2}:\d{2})\s*${utcMarker}`, "g");
const utcPoint = new RegExp(String.raw`(?<![\w/+:.-])(?:(\d{4}-\d{2}-\d{2})[T ])?(\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)\s*${utcMarker}`, "g");
function parseTime(day: string, clock: string, zone = "Z"): number | null {
const date = new Date(`${day}T00:00:00Z`);
const [hours, minutes, seconds = "0"] = clock.split(":");
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== day
|| Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) >= 60) return null;
const value = Date.parse(`${day}T${clock}${zone}`);
return Number.isFinite(value) ? value : null;
}
/** Display historical report prose without changing stored facts, IDs, URLs or code. */
export function formatDisplayText(text: string | null | undefined): string {
if (!text) return text ?? "";
return text.split(/(https?:\/\/\S+|tjwater:\/\/\S+|`[^`]*`)/g).map((part, index) => {
if (index % 2) return part;
return part.replace(utcRange, (original, day: string | undefined, start: string, end: string) => {
const startAt = parseTime(day ?? "2000-01-01", start);
let endAt = parseTime(day ?? "2000-01-01", end);
if (startAt === null || endAt === null) return original;
if (endAt < startAt) endAt += 24 * 60 * 60_000;
const dateOptions = { year: "numeric", month: "numeric", day: "numeric" } as const;
const crossesDay = formatDateTime(startAt, dateOptions) !== formatDateTime(endAt, dateOptions);
const startLabel = day ? formatDateTime(startAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" }) : formatTime(startAt);
const endLabel = crossesDay && day
? formatDateTime(endAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" })
: `${crossesDay ? "次日 " : ""}${formatTime(endAt)}`;
return `${startLabel}–${endLabel}(UTC+8)`;
}).replace(zonedTime, (original, day: string, clock: string, zone: string) => {
const value = parseTime(day, clock, zone);
return value === null ? original : `${formatDateTime(value)}(UTC+8)`;
}).replace(utcPoint, (original, day: string | undefined, clock: string) => {
const value = parseTime(day ?? "2000-01-01", clock);
if (value === null) return original;
return `${day ? formatDateTime(value) : formatTime(value)}(UTC+8)`;
});
}).join("");
}
+13
View File
@@ -19,6 +19,19 @@ export function formatTime(value: DateTimeValue) {
return formatDateTime(value, { hour: "2-digit", minute: "2-digit" });
}
/** Stable sample display, independent of the browser zone; retain source precision. */
export function formatDisplayTimestamp(value: string): string {
if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return value;
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
timeZone: DISPLAY_TIME_ZONE, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
}).formatToParts(date).map(({ type, value }) => [type, value]));
const fraction = value.match(/(\.\d+)(?:Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}${fraction}+08:00`;
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
const timestamp = Date.parse(value);