feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}
|
||||
</>;
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user