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
@@ -0,0 +1,12 @@
export type EventGpsCoordinate = [longitude: number, latitude: number];
export function formatGpsCoordinate([longitude, latitude]: EventGpsCoordinate) {
return `${longitude.toFixed(6)}, ${latitude.toFixed(6)}`;
}
export function mergeEventLocation(location: string, coordinate: EventGpsCoordinate | null) {
const text = location.trim();
if (!coordinate) return text;
const gps = `GPS:${formatGpsCoordinate(coordinate)}`;
return text ? `${text}(${gps})` : gps;
}
@@ -0,0 +1,56 @@
import { Marker, type Map as MapLibreMap } from "maplibre-gl";
import type { EventGpsCoordinate } from "./event-location-coordinate";
const arrowDirections: Record<string, [number, number]> = {
ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]
};
export function createEventLocationMarker({ map, element, coordinate, disabled, onChange, onDragChange }: {
map: MapLibreMap;
element: HTMLButtonElement;
coordinate: EventGpsCoordinate;
disabled: boolean;
onChange: (coordinate: EventGpsCoordinate) => void;
onDragChange: (dragging: boolean) => void;
}) {
const marker = new Marker({ element, anchor: "bottom", draggable: !disabled })
.setLngLat(coordinate)
.addTo(map);
element.setAttribute("aria-label", "事件位置标记,可拖拽调整");
element.setAttribute("title", "拖拽调整事件位置,方向键微调,Shift 加速");
element.setAttribute("aria-keyshortcuts", "ArrowUp ArrowDown ArrowLeft ArrowRight");
marker.on("dragstart", () => {
element.dataset.dragging = "true";
onDragChange(true);
});
marker.on("dragend", () => {
element.dataset.dragging = "false";
onDragChange(false);
const position = marker.getLngLat();
onChange([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
});
// Custom MapLibre markers own their keyboard interaction. Move in screen
// pixels and consume arrows so adjusting the pin cannot also pan the map.
element.onkeydown = (event) => {
if (!marker.isDraggable() || event.altKey || event.ctrlKey || event.metaKey) return;
const direction = arrowDirections[event.key];
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const point = map.project(marker.getLngLat());
const step = event.shiftKey ? 10 : 1;
const position = map.unproject([point.x + direction[0] * step, point.y + direction[1] * step]);
const next: EventGpsCoordinate = [normalizeLongitude(position.lng), clampLatitude(position.lat)];
marker.setLngLat(next);
onChange(next);
};
return marker;
}
export function normalizeLongitude(longitude: number) {
return ((longitude + 180) % 360 + 360) % 360 - 180;
}
export function clampLatitude(latitude: number) {
return Math.max(-90, Math.min(90, latitude));
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatGpsCoordinate, mergeEventLocation } from "./event-location-coordinate";
describe("event location GPS formatting", () => {
it("formats coordinates with stable GPS precision", () => {
expect(formatGpsCoordinate([121.4791234, 31.2359876])).toBe("121.479123, 31.235988");
});
it("keeps the written location and persists the selected coordinate", () => {
expect(mergeEventLocation(" 演示路口 ", [121.4791234, 31.2359876]))
.toBe("演示路口(GPS:121.479123, 31.235988)");
expect(mergeEventLocation("", [121.4791234, 31.2359876]))
.toBe("GPS:121.479123, 31.235988");
expect(mergeEventLocation(" 演示路口 ", null)).toBe("演示路口");
});
});
@@ -0,0 +1,242 @@
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Input } from "@/shared/ui/input";
import { RotateCcw } from "lucide-react";
import type { Marker, Map as MapLibreMap } from "maplibre-gl";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useId,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject
} from "react";
import { createPortal } from "react-dom";
import { formatGpsCoordinate, type EventGpsCoordinate } from "./event-location-coordinate";
import { EventLocationPin } from "./event-location-pin";
import { clampLatitude, createEventLocationMarker, normalizeLongitude } from "./event-location-marker";
import type { EventLocationAddressLookup } from "./use-event-location-address";
export type { EventGpsCoordinate } from "./event-location-coordinate";
type DragPreview = {
x: number;
y: number;
mapRect: DOMRect;
};
export function EventLocationPicker({ location, addressLookup, coordinate, disabled = false, mapReady, mapRef, onChange }: {
location: string;
addressLookup: EventLocationAddressLookup;
coordinate: EventGpsCoordinate | null;
disabled?: boolean;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onChange: (coordinate: EventGpsCoordinate | null) => void;
}) {
const locationId = useId();
const hintId = useId();
const reducedMotion = useReducedMotion();
const markerRef = useRef<Marker | null>(null);
const markerMapRef = useRef<MapLibreMap | null>(null);
const pointerIdRef = useRef<number | null>(null);
const dragPreviewRef = useRef<DragPreview | null>(null);
const onChangeRef = useRef(onChange);
const [dragPreview, setDragPreview] = useState<DragPreview | null>(null);
const [markerElement] = useState(() => {
const element = document.createElement("button");
element.type = "button";
element.className = "size-11 cursor-grab touch-none border-0 bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! active:cursor-grabbing disabled:cursor-default";
return element;
});
const [markerDragging, setMarkerDragging] = useState(false);
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
const moveDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
if (event.cancelable) event.preventDefault();
const current = dragPreviewRef.current;
if (!current) return;
const next = {
...current,
x: event.clientX,
y: event.clientY
};
dragPreviewRef.current = next;
setDragPreview(next);
};
const finishDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
const map = mapRef.current;
const preview = dragPreviewRef.current;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
if (!map || !preview || !containsPoint(preview.mapRect, event.clientX, event.clientY)) return;
const position = map.unproject([
event.clientX - preview.mapRect.left,
event.clientY - preview.mapRect.top
]);
onChangeRef.current([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
};
const cancelDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
};
window.addEventListener("pointermove", moveDrag, { passive: false });
window.addEventListener("pointerup", finishDrag);
window.addEventListener("pointercancel", cancelDrag);
return () => {
window.removeEventListener("pointermove", moveDrag);
window.removeEventListener("pointerup", finishDrag);
window.removeEventListener("pointercancel", cancelDrag);
};
}, [mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !coordinate) {
if (!coordinate) {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}
return;
}
if (!markerRef.current || markerMapRef.current !== map) {
markerRef.current?.remove();
const marker = createEventLocationMarker({
map, element: markerElement, coordinate, disabled,
onChange: (next) => onChangeRef.current(next),
onDragChange: setMarkerDragging
});
markerRef.current = marker;
markerMapRef.current = map;
} else {
markerRef.current.setLngLat(coordinate);
}
}, [coordinate, disabled, mapReady, mapRef, markerElement]);
useEffect(() => {
markerRef.current?.setDraggable(!disabled);
markerElement.disabled = disabled;
}, [disabled, markerElement]);
useEffect(() => () => {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}, []);
const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
const map = mapRef.current;
if (disabled || !mapReady || !map || !event.isPrimary) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
pointerIdRef.current = event.pointerId;
const mapRect = map.getContainer().getBoundingClientRect();
const preview = {
x: event.clientX,
y: event.clientY,
mapRect
};
dragPreviewRef.current = preview;
setDragPreview(preview);
};
return (
<section aria-label="事件主地图选点" className="mt-5">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 sm:grid-cols-2 sm:gap-x-4">
<div className="min-w-0">
<label htmlFor={locationId} className="block text-xs font-medium text-muted-foreground">事件位置</label>
<Input
id={locationId}
value={location}
onChange={(event) => addressLookup.changeLocation(event.target.value)}
placeholder={addressLookup.pending ? "正在获取地址…" : "选点获取地址,也可手动填写"}
title={location || undefined}
aria-busy={addressLookup.pending}
disabled={disabled}
className="mt-2 h-10 min-w-0"
/>
</div>
<div className="flex h-10 items-center gap-2 self-end">
<button
type="button"
aria-label="拖拽定位图标到主地图"
aria-describedby={hintId}
disabled={disabled || !mapReady}
data-dragging={dragPreview ? "true" : "false"}
className={cn(
"group relative grid size-11 shrink-0 touch-none place-items-center border-0 bg-transparent p-0",
"cursor-grab active:cursor-grabbing focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! disabled:cursor-not-allowed disabled:opacity-45"
)}
onPointerDown={beginDrag}
>
<motion.span
className={cn("block", dragPreview && "opacity-35")}
whileHover={disabled || reducedMotion ? undefined : { rotate: -3 }}
whileTap={disabled || reducedMotion ? undefined : { scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{ transformOrigin: "50% 100%" }}
>
<EventLocationPin />
</motion.span>
</button>
<p id={hintId} className="sr-only text-[11px] leading-4 text-muted-foreground sm:not-sr-only">拖动图钉到主地图选点</p>
</div>
<div className="col-span-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 sm:col-span-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 text-[11px] leading-4">
<span className="text-muted-foreground">GPS</span>
<output aria-label="GPS 坐标" aria-live="polite" className={cn(
"font-mono tabular-nums",
coordinate ? "text-foreground" : "text-muted-foreground"
)}>
{coordinate ? formatGpsCoordinate(coordinate) : mapReady ? "等待拖到主地图" : "主地图加载中"}
</output>
</div>
{coordinate ? (
<Button type="button" size="icon-sm" variant="ghost" aria-label="清除坐标" title="清除坐标" disabled={disabled} onClick={() => onChange(null)}>
<RotateCcw size={13} aria-hidden="true" />
</Button>
) : null}
</div>
</div>
{createPortal(<EventLocationPin raised={markerDragging} />, markerElement)}
{addressLookup.pending ? <p role="status" aria-label="地址查询状态" className="mt-1 text-xs leading-5 text-muted-foreground">正在查询地址…</p> : null}
{addressLookup.error ? (
<div role="status" aria-label="地址查询状态" className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground">
<p>{addressLookup.error}</p>
<Button type="button" variant="link" size="sm" className="px-0" disabled={disabled} onClick={addressLookup.retry}>重试地址查询</Button>
</div>
) : null}
{dragPreview ? createPortal(
<div
aria-hidden="true"
data-event-location-drag-preview=""
data-event-location-drag-pin=""
className="pointer-events-none fixed z-[80] size-11 -translate-x-1/2 -translate-y-full"
style={{ left: dragPreview.x, top: dragPreview.y }}
>
<EventLocationPin />
</div>,
document.body
) : null}
</section>
);
}
function containsPoint(rect: DOMRect, x: number, y: number) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
@@ -0,0 +1,63 @@
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
// One drawing for the picker, drag preview and map marker. The needle ends at
// the bottom centre of the viewBox, matching MapLibre's bottom anchor exactly.
export function EventLocationPin({ raised = false }: { raised?: boolean }) {
const id = useId();
const reducedMotion = useReducedMotion();
return (
<motion.svg
aria-hidden="true"
data-event-location-pin=""
viewBox="0 0 80 80"
className="pointer-events-none block size-11 overflow-visible"
initial={false}
animate={{ rotate: raised && !reducedMotion ? -4 : 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
style={{ transformOrigin: "50% 100%" }}
>
<defs>
<linearGradient id={`${id}-cap`} x1="23" y1="8" x2="53" y2="29" gradientUnits="userSpaceOnUse">
<stop stopColor="#e77c7b" />
<stop offset="0.45" stopColor="#d85157" />
<stop offset="1" stopColor="#b93b48" />
</linearGradient>
<linearGradient id={`${id}-body`} x1="30" y1="27" x2="49" y2="31" gradientUnits="userSpaceOnUse">
<stop stopColor="#df6b6d" />
<stop offset="0.4" stopColor="#d05158" />
<stop offset="1" stopColor="#b33b49" />
</linearGradient>
<linearGradient id={`${id}-foot`} x1="23" y1="35" x2="57" y2="51" gradientUnits="userSpaceOnUse">
<stop stopColor="#e47575" />
<stop offset="0.4" stopColor="#d2525b" />
<stop offset="1" stopColor="#b03b49" />
</linearGradient>
<linearGradient id={`${id}-steel`} x1="36" y1="57" x2="43" y2="59" gradientUnits="userSpaceOnUse">
<stop stopColor="#89949e" />
<stop offset="0.45" stopColor="#d9dfe4" />
<stop offset="1" stopColor="#89949e" />
</linearGradient>
<filter id={`${id}-shadow`} x="-40%" y="-30%" width="190%" height="190%">
<feDropShadow dx="1" dy="1" stdDeviation="1" floodColor="#35151b" floodOpacity="0.12" />
</filter>
<radialGradient id={`${id}-contact`}>
<stop stopColor="#36252b" stopOpacity="0.12" />
<stop offset="1" stopColor="#36252b" stopOpacity="0" />
</radialGradient>
</defs>
<ellipse cx="42" cy="78" rx="12" ry="2" fill={`url(#${id}-contact)`} />
<g transform="rotate(16 40 80)">
<path d="M36.5 47H43.5L41.5 72L40 80L38.5 72Z" fill={`url(#${id}-steel)`} />
<g filter={`url(#${id}-shadow)`}>
<path d="M22 42C22 36 30 32 40 32S58 36 58 42V46C58 52 22 52 22 46Z" fill="#aa3a47" />
<ellipse cx="40" cy="42" rx="18" ry="8" fill={`url(#${id}-foot)`} />
<path d="M29 22H51L47 34C47 38 50 39 52 41C48 46 32 46 28 41C30 39 33 38 33 34Z" fill={`url(#${id}-body)`} />
<path d="M22 17C22 7 58 7 58 17V22C58 32 22 32 22 22Z" fill={`url(#${id}-cap)`} />
<ellipse cx="40" cy="16" rx="18" ry="8" fill={`url(#${id}-cap)`} />
</g>
</g>
</motion.svg>
);
}
@@ -926,6 +926,8 @@ export interface components {
feedback_requirements?: string[] | null;
/** Location */
location?: string | null;
/** Occurred At */
occurred_at?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Progress Note */
@@ -1,5 +1,6 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
CalendarClock,
LoaderCircle,
@@ -25,12 +26,21 @@ import type {
PeripheralSampleClient,
PeripheralWorkOrder
} from "./client";
import {
EventLocationPicker,
type EventGpsCoordinate
} from "./event-location-picker";
import { mergeEventLocation } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
refreshGeneration?: number;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
@@ -89,6 +99,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
busy={busy}
mutate={mutate}
client={client}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveAddress}
onAgentTrigger={onAgentTrigger}
/>
)}
@@ -96,12 +109,15 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
);
}
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
function WorkOrders({ orders, canWrite, busy, mutate, client, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
@@ -116,9 +132,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [feedbackAt, setFeedbackAt] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
const [eventCoordinate, setEventCoordinate] = useState<EventGpsCoordinate | null>(null);
const addressLookup = useEventLocationAddress({ active: eventEditorOpen, coordinate: eventCoordinate, resolveAddress, onChange: setEventLocation });
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
@@ -140,6 +159,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setFeedbackRequirements("");
setProgressNote("");
setFeedbackAt("");
};
const closeEditor = () => {
@@ -182,6 +202,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
setProgressNote("");
setFeedbackAt(completedFeedbackTime(item));
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
@@ -199,26 +220,29 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
const waterQuality = eventKind === "water_quality_complaint";
const submittedLocation = mergeEventLocation(eventLocation, eventCoordinate);
const workOrder = await client.createWorkOrder({
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
title: waterQuality ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
summary: eventDescription.trim(),
status: "open",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
location: submittedLocation,
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
setEventCoordinate(null);
setEventDescription("");
await triggerAgent(workOrder);
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const feedbackTimeChanged = feedbackAt !== (editing ? completedFeedbackTime(editing) : "");
const canSave = canWrite && !busy && title.trim() && summary.trim() && (!feedbackTimeChanged || progressNote.trim());
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
@@ -251,6 +275,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <div className="mt-3"><DateTimeField contentClassName="workbench-shadcn-surface" label="现场反馈时间(可选)" value={feedbackAt} onChange={setFeedbackAt} disabled={!canWrite || Boolean(busy)} /><p className="mt-1 text-[11px] text-muted-foreground">选择实际反馈时间并填写现场反馈。工单完工后,Agent 从该时间起复核 SCADA;留空使用当前时间。</p></div> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -271,7 +296,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
progress_note: progressNote.trim() || null,
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
});
} else {
await client.createWorkOrder({
@@ -334,16 +360,16 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div>
{eventEditorOpen ? (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4 sm:p-5">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="mt-4 grid gap-4 sm:grid-cols-2 [&>div>label]:mb-2 [&>div>label]:leading-4">
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
<EventLocationPicker location={eventLocation} addressLookup={addressLookup} coordinate={eventCoordinate} onChange={setEventCoordinate} disabled={!canWrite || Boolean(busy)} mapReady={mapReady} mapRef={mapRef} />
<label className="mt-5 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-2 min-h-20" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-5 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || addressLookup.pending || (!eventLocation.trim() && !eventCoordinate) || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
</Button>
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
@@ -574,3 +600,9 @@ function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
function completedFeedbackTime(item: PeripheralWorkOrder): string {
if (item.status !== "completed") return "";
const feedback = (item.activity ?? []).filter((entry) => entry.kind === "feedback").at(-1);
return feedback ? toLocalInput(new Date(feedback.occurred_at)) : "";
}
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
import { reverseGeocodePoint } from "../api/place-search-client";
import type { EventLocationAddressResolver } from "./use-event-location-address";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
@@ -21,12 +24,18 @@ export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
onAgentTrigger
mapReady,
mapRef,
onAgentTrigger,
onOpenInvestigation
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -37,6 +46,10 @@ export function PeripheralSamplePanel({
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [scadaRefreshGeneration, setScadaRefreshGeneration] = useState(0);
const [activeSystem, setActiveSystem] = useState<"work-orders" | "scada">("work-orders");
const resolveEventAddress = useCallback<EventLocationAddressResolver>(async ([longitude, latitude], signal) => {
const result = await reverseGeocodePoint(longitude, latitude, context?.project_id ?? null, getAccessToken, signal);
return result.address.trim() || result.name;
}, [context?.project_id, getAccessToken]);
const refresh = useCallback(async () => {
if (!client.configured) return;
@@ -120,10 +133,14 @@ export function PeripheralSamplePanel({
{context ? (
<div className="space-y-3">
<ManagedTestResources
key={context.project_id}
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
refreshGeneration={refreshGeneration}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveEventAddress}
onAgentTrigger={onAgentTrigger}
/>
<Disclosure
@@ -168,6 +185,7 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"}
getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration}
onOpenInvestigation={onOpenInvestigation}
/>
</TabsContent>
</Tabs>
@@ -0,0 +1,82 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
const preference = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...await original<typeof import("motion/react")>(), useReducedMotion: () => preference.reduced
}));
const result: ScadaMonitoringResult = {
monitoring_id: "70000000-0000-4000-8000-000000000001", period_start: "2026-07-15T23:45:00Z",
period_end: "2026-07-16T00:00:00Z", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 2,
rule_version: "burst-monitor-v2", status: "attention", task_id: "80000000-0000-4000-8000-000000000001",
detail: "详细压力与流量依据应保留在报告中", pressure: {}, outlet_flow: {}, burst_detection: {},
weighted: { score: 0.1, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", action: "raised", status: "active",
started_at: "2026-07-16T00:00:00Z", last_observed_at: "2026-07-16T00:00:00Z", recovery_count: 0 }
};
afterEach(() => { cleanup(); vi.useRealTimers(); preference.reduced = false; });
it("keeps notifications short and opens the existing investigation", () => {
const onOpen = vi.fn();
render(<ScadaMonitoringNotice result={result} animationId={null} processing={false} onOpenInvestigation={onOpen} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByText("已启动调查")).toBeVisible();
expect(screen.queryByText(result.detail)).not.toBeInTheDocument();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "warning");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onOpen).toHaveBeenCalledWith(result.task_id);
});
it("uses danger color only when the monitoring decision supports suspected burst", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "suspected_burst" }} animationId={null} processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "danger");
});
it("finishes the one-time animation without restarting for the next period of the same incident", () => {
vi.useFakeTimers();
const { rerender } = render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "running");
act(() => vi.advanceTimersByTime(1000));
rerender(<ScadaMonitoringNotice result={{ ...result, incident: { ...result.incident!, action: "ongoing" } }}
animationId="accident-1" processing={false} />);
act(() => vi.advanceTimersByTime(1600));
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByText("沿用原调查")).toBeVisible();
});
it("retains the anomaly message with reduced motion and suppresses animation", () => {
preference.reduced = true;
render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status").querySelector("[data-slot=scada-alert-emphasis]")).toBeNull();
});
it("shows a neutral standby state before monitoring starts", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing={false} />);
expect(screen.getByText("监测待机")).toBeVisible();
expect(screen.getByText("等待播放或单步监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "neutral");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
it("shows pending progress while waiting for the first monitoring result", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing />);
expect(screen.getByText("正在监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
expect(screen.queryByText("监测待机")).not.toBeInTheDocument();
});
it("uses success styling for a normal result and keeps its timestamp during the next check", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "normal", task_id: null, incident: null }} animationId={null} processing />);
expect(screen.getByText("监测正常")).toBeVisible();
expect(screen.getByText("本周期未发现异常")).toBeVisible();
expect(screen.getByText("正在监测下一周期")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "success");
expect(screen.getByText(/07\/16/)).toHaveAttribute("dateTime", result.period_end);
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
export function ScadaMonitoringNotice({ result, animationId, processing, onOpenInvestigation }: {
result: ScadaMonitoringResult | null | undefined;
animationId: string | null;
processing: boolean;
onOpenInvestigation?: (taskId: string) => void;
}) {
const reduced = useReducedMotion();
const [animating, setAnimating] = useState(false);
useEffect(() => {
if (!animationId || reduced) return;
setAnimating(true);
const timer = window.setTimeout(() => setAnimating(false), 2600);
return () => window.clearTimeout(timer);
}, [animationId, reduced]);
const recovered = result?.incident?.action === "resolved";
const ongoing = result?.incident?.action === "ongoing";
const abnormal = Boolean(result && !recovered && (result.task_id || ["attention", "suspected_burst"].includes(result.status)));
const suspectedBurst = abnormal && result?.status === "suspected_burst";
const normal = result?.status === "normal" || recovered;
const tone: StatusTone = suspectedBurst ? "danger" : abnormal ? "warning" : normal ? "success" : processing ? "info" : "neutral";
const Icon = !result ? processing ? LoaderCircle : Clock3 : abnormal ? TriangleAlert : normal ? CheckCircle2 : CircleHelp;
const title = !result ? processing ? "正在监测" : "监测待机"
: recovered ? "异常已恢复" : ongoing ? "异常持续跟踪" : suspectedBurst ? "发现疑似爆管" : abnormal ? "发现指标异常"
: normal ? "监测正常" : "监测数据不足";
const note = !result ? processing ? "正在核对本周期数据" : "等待播放或单步监测"
: result.task_id ? recovered ? "告警已解除" : ongoing ? "沿用原调查" : "已启动调查"
: normal ? "本周期未发现异常" : abnormal ? "请留意后续监测结果" : "暂无法形成监测结论";
const hint = processing ? result ? "正在监测下一周期" : "处理完成后更新结果"
: abnormal ? "诊断详情见调查报告" : recovered ? "可回看本次调查"
: result && !normal ? "待后续周期继续监测" : "异常时自动启动调查";
const emphasis = animating && abnormal && !reduced;
return (
<section aria-label="最新监测结果" role="status" aria-busy={processing}
aria-atomic="true"
data-animation-state={emphasis ? "running" : "idle"} data-announcement-id={animationId ?? undefined}
data-status-tone={tone}
className={cn(
"relative isolate grid h-40 shrink-0 grid-rows-[1.25rem_1fr_2.25rem] gap-2 overflow-hidden rounded-lg bg-[var(--status-soft)] p-3.5 text-foreground ring-1 ring-inset ring-[var(--status-border)]",
`status-tone-${tone}`
)}>
{emphasis ? <motion.div key={`${animationId}-surface`} aria-hidden="true"
data-slot="scada-alert-emphasis" className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.55, 0] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<div data-slot="scada-notice-header" className="flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/75">
<span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<span className="sr-only">最新监测 </span>
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })}
</time> : <span>尚无监测结果</span>}
</div>
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
<span className="relative shrink-0" aria-hidden="true">
{emphasis ? <motion.span key={animationId} data-slot="scada-alert-pulse"
className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: [0, 0.65, 0], scale: [0.8, 1.35, 1.65] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<StatusIcon tone={tone} size="md" className="size-9 bg-background/70"
activity={!result && processing ? "loading" : "static"}>
<motion.span key={`${animationId}-icon`} animate={emphasis ? { rotate: [0, -12, 12, -6, 0] } : { rotate: 0 }}
transition={{ duration: 0.5 }}><Icon size={18} /></motion.span>
</StatusIcon>
</span>
<div data-slot="scada-notice-summary" className="min-w-0 flex-1 space-y-0.5 text-xs leading-5">
<p className="font-semibold">{title}</p>
<p className="text-foreground/75">{note}</p>
</div>
</div>
<div data-slot="scada-notice-footer" className="flex min-w-0 items-center justify-between gap-2 border-t border-[var(--status-border)] pt-1.5">
<p className="text-[11px] leading-4 text-foreground/75">{hint}</p>
<div data-slot="scada-notice-action" className="flex h-8 w-[5.5rem] shrink-0 justify-end">
{result?.task_id && onOpenInvestigation ? <Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
onClick={() => onOpenInvestigation(result.task_id!)}>
查看调查<ArrowUpRight size={12} aria-hidden="true" />
</Button> : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,74 @@
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: {
config: ScadaMonitoringConfig;
calibration?: ScadaMonitoringCalibration | null;
frozen: boolean;
}) {
const weighted = config.decision_policy === "weighted";
const currentPolicy = calibration?.version === "scada-monitor-calibration@2"
&& calibration.diagnostic_policy === "normal-phase-envelope@1";
const usable = currentPolicy ? calibration : null;
const ready = usable?.status === "ready";
const indicators = [
{ key: "burst_detection", label: "爆管侦测", threshold: ready && usable.burst_detection?.threshold != null
? `得分 ≤ ${usable.burst_detection.threshold.toFixed(4)}` : "由前 15 天正常历史计算" },
{ key: "pressure", label: "压力下降", threshold: thresholdLabel(config.pressure_device_ids, usable?.pressure, "下降") },
{ key: "outlet_flow", label: "出厂流量", threshold: config.outlet_flow_device_ids.length
? thresholdLabel(config.outlet_flow_device_ids, usable?.outlet_flow, "增加") : "未配置,贡献为 0" }
] as const;
return (
<section aria-label="组合监测规则" className="space-y-3 text-xs leading-5">
<div className="space-y-1">
<h4 className="font-semibold text-foreground">SCADA 异常触发规则</h4>
<p className="text-muted-foreground">压力、流量越过历史同期正常范围,或水质越过已配置上下限,即报警并启动调查。</p>
<ol aria-label="诊断流程" className="flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<li>数据诊断</li><li aria-hidden="true">→</li><li>指标异常</li><li aria-hidden="true">→</li><li>爆管诊断</li><li aria-hidden="true">→</li><li>报告</li>
</ol>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h4 className="font-semibold text-foreground">{weighted ? "爆管诊断 · 三指标加权" : "爆管诊断 · 三指标联合判定"}</h4>
<span className="text-[11px] text-muted-foreground">{frozen ? "本轮已固定" : "下一轮使用项目配置"}</span>
</div>
{weighted ? (
<>
<dl className="divide-y divide-border">
{indicators.map(({ key, label, threshold }) => (
<div key={key} className="grid grid-cols-[1fr_auto] gap-x-3 py-2 first:pt-0">
<dt className="text-foreground">{label}</dt>
<dd className="font-medium text-foreground tabular-nums">权重 {(config.weights[key] * 100).toFixed(0)}%</dd>
<dd className="col-span-2 text-[11px] text-muted-foreground tabular-nums">{threshold}</dd>
</div>
))}
</dl>
<p className="font-medium text-foreground tabular-nums">综合分 ≥ {config.alert_threshold.toFixed(2)} 时标记疑似爆管</p>
<p className="text-[11px] text-muted-foreground">加权分用于评估爆管嫌疑,任一指标异常即启动调查。</p>
<p className="text-[11px] text-muted-foreground">压力下降与出厂流量增加需同时越过动态变化阈值和历史同期正常范围。正常日的日内波动由同期范围校验。</p>
<p className="text-[11px] text-muted-foreground">指标命中后计入对应权重;流量缺数按 0 计,不重新分配权重。压力或侦测数据不足时暂不作出组合判定。</p>
<div className="space-y-1 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">动态阈值 · {ready ? "本轮已冻结" : "等待历史基线"}</p>
{ready ? <p className="tabular-nums">历史区间 {dateTime(usable.history_start)} 至 {dateTime(usable.knowledge_cutoff)}(不含结束时刻)</p> : <p>{calibration && !currentPolicy ? "旧版校准已停用,请重新开始本轮。" : calibration ? "历史基线不足,动态阈值尚未就绪。" : "开始监测后,以演示开始前 15 天的正常历史逐测点计算阈值,并在本轮固定。"}</p>}
</div>
</>
) : (
<p className="text-muted-foreground">本轮使用三指标联合评估爆管嫌疑;任一经历史基线校验的指标异常即启动调查。</p>
)}
<p className="text-[11px] text-muted-foreground">同一异常只报警并建单一次,持续异常沿用原工单;连续两个正常周期且相关测点恢复后解除告警。</p>
<p className="text-[11px] text-muted-foreground tabular-nums">压力 {config.pressure_device_ids.length} 个测点 · 出厂流量 {config.outlet_flow_device_ids.length} 个测点 · {config.rule_version}</p>
</section>
);
}
function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pressure"] | undefined, direction: string) {
const thresholds = ids.flatMap((id) => profiles?.[id] ? [profiles[id].threshold_percent] : []);
if (!thresholds.length) return "逐测点动态计算变化阈值";
const lower = Math.min(...thresholds).toFixed(2);
const upper = Math.max(...thresholds).toFixed(2);
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
}
function dateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
@@ -0,0 +1,37 @@
import { Activity, Clock3, Gauge, Timer } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { SCADA_PLAYBACK_SPEED } from "./use-scada-playback";
import { formatMonitoringInterval } from "./scada-replay-time";
export function ScadaMonitoringStatus({ status, playing, processing, intervalSeconds, count, progress }: {
status: string;
playing: boolean;
processing: boolean;
intervalSeconds?: number | null;
count: number;
progress: number;
}) {
return <section aria-label="SCADA 演示状态" className="space-y-3 rounded-lg bg-muted/60 p-3.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<StatusBadge tone={playing || processing ? "info" : "neutral"} icon={<Activity size={13} />}>
{status}
</StatusBadge>
<span className="text-xs text-muted-foreground tabular-nums">已监测 <strong className="font-medium text-foreground">{count}</strong> 个周期</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
{[
{ label: "监测周期", value: intervalSeconds ? formatMonitoringInterval(intervalSeconds) : "未配置", Icon: Clock3 },
{ label: "演示倍速", value: `${SCADA_PLAYBACK_SPEED}×`, Icon: Gauge },
{ label: "每步播放", value: intervalSeconds ? `约 ${Number((intervalSeconds / SCADA_PLAYBACK_SPEED).toFixed(1))} 秒` : "待配置", Icon: Timer }
].map(({ label, value, Icon }) => <div key={label} className="min-w-0 space-y-1">
<dt className="flex flex-wrap items-center gap-1 text-[11px] leading-5 text-muted-foreground"><Icon size={12} aria-hidden="true" />{label}</dt>
<dd className="font-semibold leading-6 tabular-nums text-foreground">{value}</dd>
</div>)}
</dl>
<div role="progressbar" aria-label="演示区间进度" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)} className="h-1 overflow-hidden rounded-full bg-border">
<div className="h-full origin-left bg-primary" style={{ transform: `scaleX(${progress})` }} />
</div>
<p className="text-[11px] leading-5 text-muted-foreground">每步另需后台处理时间;关闭面板或切到后台会暂停播放。</p>
</section>;
}
@@ -1,8 +1,8 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LatestScadaReadings } from "../api/scada-client";
import type { ScadaReplay } from "../api/scada-replay-client";
import type { ScadaMonitoringConfig, ScadaReplay } from "../api/scada-replay-client";
import { reuseReplayProjection, reuseScadaReadings } from "./scada-replay-readings";
import { ScadaReplaySection } from "./scada-replay-section";
@@ -38,6 +38,12 @@ const catalog: LatestScadaReadings = {
latest_observed_at: "2026-10-22T01:00:00Z"
};
const projectRules: ScadaMonitoringConfig = {
rule_version: "burst-monitor-v2", decision_policy: "weighted",
pressure_device_ids: ["P1"], outlet_flow_device_ids: ["F1"],
weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 }, alert_threshold: 0.8
};
function replay(): ScadaReplay {
return {
replay_id: "60000000-0000-4000-8000-000000000001",
@@ -116,6 +122,53 @@ describe("SCADA replay panel", () => {
expect(command).not.toHaveBeenCalled();
});
it("returns the pointer to the range beginning without resetting backend progress", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: Date.parse("2026-10-22T01:15:00Z") } });
fireEvent.click(screen.getByRole("button", { name: "回到开始时间" }));
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(current.start_time));
expect(screen.getByRole("button", { name: "回到开始时间" })).toBeDisabled();
expect(current.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
});
it("monitors the pointer's exact selected time without sending a next-step command", async () => {
const target = Date.parse("2026-10-22T01:05:00Z");
const backend = useReplay();
const monitorAt = vi.fn(async () => ({ ...current, virtual_now: new Date(target).toISOString() }));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(expect.objectContaining({ replay_id: current.replay_id }), new Date(target).toISOString()));
expect(command).not.toHaveBeenCalled();
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target);
});
it("prepares a separate causal round before monitoring a historical selection", async () => {
const original = current;
const target = Date.parse("2026-10-22T00:15:00Z");
const backend = useReplay();
const created = { ...original, replay_id: "60000000-0000-4000-8000-000000000002", status: "ready", virtual_now: original.start_time } as ScadaReplay;
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => created);
const monitorAt = vi.fn(async () => ({ ...created, status: "paused", virtual_now: new Date(target).toISOString() } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, command: stop, create, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
expect(screen.getByText("监测此时刻将建立新一轮回放,原有调查记录保留。")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(created, new Date(target).toISOString()));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create.mock.invocationCallOrder[0]).toBeLessThan(monitorAt.mock.invocationCallOrder[0]);
expect(original.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target));
});
it("retains new evidence and separates otherwise identical replay runs", () => {
const incoming = structuredClone(current.latest);
expect(reuseScadaReadings(current.latest, incoming)).toBe(current.latest);
@@ -135,6 +188,8 @@ describe("SCADA replay panel", () => {
expect(screen.getByLabelText("回看位置测试输入")).toHaveAttribute("data-step-ms", "300000");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("15 分钟");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("180×");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("5 秒");
expect(screen.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
});
@@ -146,6 +201,109 @@ describe("SCADA replay panel", () => {
expect(command).toHaveBeenCalledWith("step");
});
it("shows project weighted rules before starting without claiming learned thresholds", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null,
capabilities: { ...backend.capabilities, monitoring_config: projectRules } });
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("权重 50%")).toBeVisible();
expect(screen.getByText("权重 40%")).toBeVisible();
expect(screen.getByText("权重 10%")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("shows the active replay frozen rules and learned thresholds instead of current project settings", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend,
capabilities: { ...backend.capabilities, monitoring_config: { ...projectRules, alert_threshold: 0.7 } } });
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "ready", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 6.5 } }, outlet_flow: { F1: { threshold_percent: 8.5 } },
burst_detection: { threshold: -0.0843 }
};
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("本轮已固定")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("得分 ≤ -0.0843")).toBeVisible();
expect(screen.getByText("下降阈值 6.50%")).toBeVisible();
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.queryByText("变化阈值 30%")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("identifies an explicitly configured legacy combined policy", () => {
current.monitoring_config = { ...projectRules, decision_policy: "all_signals" };
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("爆管诊断 · 三指标联合判定", { selector: "h4" })).toBeVisible();
expect(screen.queryByText("权重 50%")).not.toBeInTheDocument();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("defaults a new demonstration to one hour around the Case3 event", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null });
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00"));
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("stops an old replay before switching its display to the Case3 hour", async () => {
const backend = useReplay();
const stop = vi.fn(async () => {
current = { ...current, status: "stopped" };
return current;
});
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop }, change: (operation: () => Promise<ScadaReplay>) => operation() }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "切换到 Case3" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00")));
expect(stop).toHaveBeenCalledWith(expect.objectContaining({ status: "paused" }), "stop");
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("retains learned flow thresholds while pressure baseline is still incomplete", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "insufficient_data", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: {}, outlet_flow: { F1: { threshold_percent: 8.5 } }
};
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.getByText("加权分用于评估爆管嫌疑,任一指标异常即启动调查。")).toBeVisible();
});
it("requires rebuilding a warmed replay with obsolete calibration", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@1", status: "ready",
history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 30 } }, outlet_flow: {}
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本轮使用旧版诊断")).toBeVisible();
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一步并监测" })).toBeDisabled();
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("旧版校准已停用,请重新开始本轮。")).toBeVisible();
expect(screen.queryByText("下降阈值 30.00%")).not.toBeInTheDocument();
});
it("shows the persisted monitoring decision and linked burst investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
@@ -165,8 +323,80 @@ describe("SCADA replay panel", () => {
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本周期疑似爆管")).toBeVisible();
expect(screen.getByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).toBeVisible();
expect(screen.getByText("已进入爆管调查与定位流程")).toBeVisible();
expect(screen.getByText("发现疑似爆管")).toBeVisible();
expect(screen.queryByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).not.toBeInTheDocument();
expect(screen.getByText("已启动调查")).toBeVisible();
});
it("shows the continuing accident and weighted evidence without offering another investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: "2026-10-22T00:45:00Z", period_end: "2026-10-22T01:00:00Z",
knowledge_cutoff: "2026-10-22T01:00:00Z", batch_sequence: 9, rule_version: "burst-monitor-v2",
status: "insufficient_data", detail: "本周期数据不足,继续跟踪原事故",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: { status: "insufficient_data", hit: false }, outlet_flow: { status: "ready", hit: false },
burst_detection: { status: "insufficient_data", hit: false },
weighted: { score: 0, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", status: "active", action: "ongoing",
started_at: "2026-10-22T00:30:00Z", last_observed_at: "2026-10-22T01:00:00Z", recovery_count: 0 }
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("异常持续跟踪")).toBeVisible();
expect(screen.getByText("沿用原调查")).toBeVisible();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.queryByText("判定依据")).not.toBeInTheDocument();
expect(command).not.toHaveBeenCalled();
});
it("separates play and pause controls and keeps the play label after pausing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
const play = screen.getByRole("button", { name: "播放" });
const pause = screen.getByRole("button", { name: "暂停" });
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
fireEvent.click(play);
expect(play).toBeDisabled();
expect(pause).toBeEnabled();
fireEvent.click(pause);
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
expect(command).not.toHaveBeenCalled();
});
it("stops the previous round and prepares a fresh paused round without stepping it", async () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const original = current;
const backend = useReplay();
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => ({ ...original, replay_id: "60000000-0000-4000-8000-000000000002",
status: "ready", virtual_now: original.start_time, monitoring_count: 0, processed_count: 0, last_monitoring: null } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop, create },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "重新开始" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(original.start_time)));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create).toHaveBeenCalledWith(expect.objectContaining({ start_time: new Date(original.start_time).toISOString(), end_time: new Date(original.end_time).toISOString(), device_ids: ["P1"] }));
expect(command).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "播放" })).toBeEnabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveTextContent("监测待机");
});
it("requires an explicit restart after completion and blocks restart during pending processing", () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const backend = useReplay();
current = { ...current, status: "completed", start_time: "2026-07-15T23:30:00Z", end_time: "2026-07-16T00:30:00Z", virtual_now: "2026-07-16T00:30:00Z" };
useReplay.mockImplementation(() => ({ ...backend, replay: current }));
const { rerender } = render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("button", { name: "重新开始" })).toBeEnabled();
current = { ...current, status: "paused", step_pending: true };
rerender(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "重新开始" })).toBeDisabled();
});
});
@@ -1,30 +1,32 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
CheckCircle2,
ChevronDown,
CircleHelp,
RotateCcw,
SkipForward,
Square,
TriangleAlert
Pause,
Play
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import type { LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import type { ScadaReplay, ScadaReplayRule } from "../api/scada-replay-client";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { formatMonitoringInterval, snapTimelineTime } from "./scada-replay-time";
import { snapTimelineTime } from "./scada-replay-time";
import { ScadaTimeTuner } from "./scada-time-tuner";
import { ScadaMonitoringRules } from "./scada-monitoring-rules";
import { useScadaReplay } from "./use-scada-replay";
import { useScadaPlayback } from "./use-scada-playback";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
import { ScadaMonitoringStatus } from "./scada-monitoring-status";
const ACTIVE = new Set(["ready", "paused"]);
const TIMELINE_STEP_MS = 5 * 60_000;
const floorToTimelineStep = (time: number) => Math.floor(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
const ceilToTimelineStep = (time: number) => Math.ceil(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
const CASE3_START = Date.parse("2026-07-16T07:30:00+08:00");
const CASE3_END = Date.parse("2026-07-16T08:30:00+08:00");
const STATUS: Record<ScadaReplay["status"], string> = {
ready: "待监测",
paused: "等待下一步",
@@ -32,50 +34,22 @@ const STATUS: Record<ScadaReplay["status"], string> = {
stopped: "本轮已停止",
failed: "处理失败"
};
const MONITORING_STATUS = {
normal: {
title: "本周期监测正常",
icon: CheckCircle2,
className: "bg-emerald-50/80 text-emerald-950 ring-emerald-600/20",
iconClassName: "text-emerald-700"
},
attention: {
title: "本周期需要关注",
icon: TriangleAlert,
className: "bg-amber-50/90 text-amber-950 ring-amber-600/20",
iconClassName: "text-amber-700"
},
insufficient_data: {
title: "本周期数据不足",
icon: CircleHelp,
className: "bg-[var(--surface-control)] text-slate-800 ring-[var(--surface-border)]",
iconClassName: "text-slate-500"
},
suspected_burst: {
title: "本周期疑似爆管",
icon: TriangleAlert,
className: "bg-rose-50/90 text-rose-950 ring-rose-600/20",
iconClassName: "text-rose-700"
}
};
export function ScadaReplaySection({
active,
catalog,
getAccessToken
getAccessToken,
onOpenInvestigation
}: {
active: boolean;
catalog: LatestScadaReadings;
getAccessToken?: WorkbenchAccess;
onOpenInvestigation?: (taskId: string) => void;
}) {
const backend = useScadaReplay(active, getAccessToken);
const { replay, capabilities, busy } = backend;
const [start, setStart] = useState(() => localTime(
floorToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()) - 24 * 60 * 60_000)
));
const [end, setEnd] = useState(() => localTime(
ceilToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()))
));
const [start, setStart] = useState(() => localTime(CASE3_START));
const [end, setEnd] = useState(() => localTime(CASE3_END));
const [threshold, setThreshold] = useState("30");
const [qualityMetric, setQualityMetric] = useState("");
const [qualityLower, setQualityLower] = useState("");
@@ -85,22 +59,28 @@ export function ScadaReplaySection({
));
const [view, setView] = useState<number | null>(null);
const restoredId = useRef<string | undefined>(undefined);
const announced = useRef(new Set<string>());
const [animationId, setAnimationId] = useState<string | null>(null);
const [monitoringSelection, setMonitoringSelection] = useState(false);
const locked = Boolean(replay && ACTIVE.has(replay.status));
const rulesFrozen = Boolean(replay && ["ready", "paused", "failed"].includes(replay.status));
const monitoringConfig = rulesFrozen ? replay?.monitoring_config : capabilities?.monitoring_config;
const obsoleteCalibration = Boolean(rulesFrozen && replay?.monitoring_config?.decision_policy === "weighted"
&& (replay.processed_count > 0 || replay.monitoring_calibration)
&& (replay.monitoring_calibration?.version !== "scada-monitor-calibration@2"
|| replay.monitoring_calibration?.diagnostic_policy !== "normal-phase-envelope@1"));
const interval = locked && replay
? replay.monitoring_interval_seconds
: capabilities?.monitoring_interval_seconds;
const intervalLabel = interval ? formatMonitoringInterval(interval) : null;
const startMs = locked && replay ? Date.parse(replay.start_time) : Date.parse(start);
const endMs = locked && replay ? Date.parse(replay.end_time) : Date.parse(end);
const now = replay ? Date.parse(replay.virtual_now) : startMs;
const displayTime = snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
const matchingReplay = replay && Date.parse(replay.start_time) === startMs && Date.parse(replay.end_time) === endMs
? replay : null;
const case3Range = startMs === CASE3_START && endMs === CASE3_END;
const now = matchingReplay ? Date.parse(matchingReplay.virtual_now) : startMs;
const processedDisplayTime = replay?.processed_until
? floorToTimelineStep(Date.parse(replay.processed_until))
: null;
const future = Boolean(
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
);
const types = [...new Set(catalog.items.map((item) => item.device_type))];
const selectedDevices = catalog.items.filter((item) => selectedTypes.has(item.device_type));
const rules = useMemo(() => {
const groups = new Map<string, typeof catalog.items>();
@@ -157,6 +137,7 @@ export function ScadaReplaySection({
useEffect(() => {
if (!replay || restoredId.current === replay.replay_id) return;
restoredId.current = replay.replay_id;
if (!["ready", "paused", "failed"].includes(replay.status)) return;
setView(null);
setStart(localTime(Date.parse(replay.start_time)));
setEnd(localTime(Date.parse(replay.end_time)));
@@ -169,47 +150,146 @@ export function ScadaReplaySection({
setQualityUpper(water?.upper == null ? "" : String(water.upper));
}, [replay]);
const advance = () => {
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
return backend.command("step");
const announceMonitoringResult = (next: ScadaReplay | null) => {
const result = next?.last_monitoring;
if (result && (result.task_id || ["attention", "suspected_burst"].includes(result.status))
&& !["ongoing", "resolved"].includes(result.incident?.action ?? "")) {
const id = result.incident?.incident_id ?? result.task_id ?? result.monitoring_id;
if (!announced.current.has(id)) {
announced.current.add(id);
setAnimationId(id);
}
}
if (!valid) return;
return backend.change(async () => {
const created = await backend.client.create({
start_time: new Date(startMs).toISOString(),
end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id),
rules
};
const advance = async (): Promise<ScadaReplay | null> => {
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
let next: ScadaReplay | null;
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
next = await backend.command("step");
} else {
if (!valid) return null;
next = await backend.change(async () => {
const created = await backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
return backend.client.command(created, "step");
});
return backend.client.command(created, "step");
});
}
announceMonitoringResult(next);
return next;
};
const canMonitor = Boolean(interval && capabilities?.available && capabilities.can_write && (locked || valid)
&& !backend.error && !obsoleteCalibration
&& (!replay || ["ready", "paused", "completed", "stopped"].includes(replay.status)));
const playback = useScadaPlayback({
active, enabled: canMonitor, busy: busy || Boolean(replay?.step_pending),
start: startMs, end: endMs, now, intervalSeconds: interval,
restart: Boolean(replay && ["completed", "stopped"].includes(replay.status)), advance
});
const displayTime = view === null && playback.started ? playback.time
: snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
const future = Boolean(
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
);
const selectionNeedsNewRound = Boolean(matchingReplay && (displayTime < now
|| ["stopped", "failed"].includes(matchingReplay.status) || obsoleteCalibration));
const monitorSelection = async () => {
playback.pause();
const target = new Date(displayTime).toISOString();
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
setMonitoringSelection(true);
try {
const next = await backend.change(async () => {
let current = matchingReplay;
if (!current || selectionNeedsNewRound) {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
current = await backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
}
return backend.client.monitorAt(current, target);
});
announceMonitoringResult(next);
} finally {
setMonitoringSelection(false);
}
};
const monitoring = replay?.last_monitoring
? MONITORING_STATUS[replay.last_monitoring.status]
: null;
const MonitoringIcon = monitoring?.icon;
const stepDisabled = busy || replay?.step_pending || !interval
|| !capabilities?.available || !capabilities.can_write
|| (!locked && !valid)
|| Boolean(replay && !["ready", "paused", "completed", "stopped"].includes(replay.status));
const selectCase3 = () => {
playback.pause();
const apply = () => {
setStart(localTime(CASE3_START));
setEnd(localTime(CASE3_END));
setView(null);
setSelectedTypes(new Set(catalog.items.map((item) => item.device_type)));
};
if (rulesFrozen && replay) {
return backend.change(async () => {
const stopped = await backend.client.command(replay, "stop");
apply();
return stopped;
});
}
apply();
};
const restarting = playback.newRound;
const requiresRestart = Boolean(matchingReplay && ["completed", "stopped"].includes(matchingReplay.status));
const restart = async () => {
playback.pause();
const next = await backend.change(async () => {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
return backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
});
if (next) {
setView(null);
setAnimationId(null);
playback.reset();
}
};
const result = restarting ? null : matchingReplay?.last_monitoring;
const nextTime = interval ? Math.min(endMs,
startMs + (Math.floor(((restarting ? startMs : now) - startMs) / (interval * 1000)) + 1) * interval * 1000
) : null;
const stepDisabled = busy || replay?.step_pending || !canMonitor || requiresRestart;
const restartDisabled = busy || replay?.step_pending || !capabilities?.available || !capabilities.can_write
|| !valid || (!matchingReplay && !playback.started);
const selectionDisabled = busy || replay?.step_pending || playback.playing
|| !capabilities?.available || !capabilities.can_write || !valid;
return (
<WorkbenchSection
title="SCADA 监测演示"
description="时间轴按 5 分钟定位,监测按钮按项目周期触发异常分析。"
title="SCADA 数据诊断"
description="自动播放并按周期监测,发现异常后启动调查。"
icon={<Activity size={15} />}
tone="teal"
bodyClassName="space-y-4"
bodyClassName="space-y-3"
>
{capabilities && !capabilities.available ? (
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
{capabilities.reason}
</WorkbenchNotice>
) : null}
{backend.error || replay?.error ? (
{backend.error || matchingReplay?.error ? (
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
{backend.error ?? replay?.error}
{backend.error ?? matchingReplay?.error}
</WorkbenchNotice>
) : null}
{obsoleteCalibration ? (
<WorkbenchNotice tone="warning" title="本轮使用旧版诊断">
旧版判断已停用,请重新开始本轮,以使用历史同期正常范围。
</WorkbenchNotice>
) : null}
{capabilities && !interval ? (
@@ -223,221 +303,135 @@ export function ScadaReplaySection({
</WorkbenchNotice>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<DateTimeField
label="演示开始时间"
value={locked && replay ? localTime(startMs) : start}
onChange={setStart}
minuteStep={5}
disabled={locked || busy}
contentClassName="workbench-shadcn-surface"
/>
<DateTimeField
label="演示结束时间"
value={locked && replay ? localTime(endMs) : end}
onChange={setEnd}
minuteStep={5}
disabled={locked || busy}
contentClassName="workbench-shadcn-surface"
/>
</div>
<ScadaMonitoringStatus
status={replay?.step_pending || busy ? "正在处理监测数据" : playback.playing ? "自动播放中" : matchingReplay?.status === "completed" && !restarting ? "播放完成" : playback.started ? "播放已暂停" : matchingReplay ? STATUS[matchingReplay.status] : "尚未开始"}
playing={playback.playing}
processing={busy || Boolean(replay?.step_pending)}
intervalSeconds={interval}
count={restarting ? 0 : matchingReplay?.monitoring_count ?? 0}
progress={Number.isFinite(startMs) && endMs > startMs
? Math.max(0, Math.min(1, ((playback.started ? playback.time : now) - startMs) / (endMs - startMs))) : 0}
/>
<div
aria-label="SCADA 演示状态"
className="grid grid-cols-3 divide-x divide-[var(--surface-divider)] overflow-hidden rounded-lg bg-[var(--surface-control)] ring-1 ring-inset ring-[var(--surface-divider)]"
>
<Readout label="监测步长" value={intervalLabel ?? "未配置"} />
<Readout label="当前状态" value={replay ? STATUS[replay.status] : "尚未开始"} />
<Readout label="已监测周期" value={String(replay?.monitoring_count ?? 0)} />
</div>
<ScadaMonitoringNotice result={result} animationId={animationId}
processing={busy || Boolean(replay?.step_pending)}
onOpenInvestigation={onOpenInvestigation ? (taskId) => { playback.pause(); onOpenInvestigation(taskId); } : undefined} />
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
<div className="py-1">
<ScadaTimeTuner
start={startMs}
end={endMs}
value={Math.max(startMs, Math.min(endMs, displayTime))}
step={TIMELINE_STEP_MS}
following={false}
browsing={view !== null}
onChange={setView}
/>
<section aria-label="SCADA 回放控制" className="overflow-hidden rounded-xl border border-border bg-background">
<div className="space-y-3 p-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>Case3 · 事件 07/16 08:00 · 北京时间</span>
{case3Range ? <span>事件前后各 30 分钟</span> : <Button size="sm" variant="ghost"
disabled={busy || replay?.step_pending || !capabilities?.can_write}
onClick={() => void selectCase3()}>切换到 Case3</Button>}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<DateTimeField
label="演示开始时间"
value={locked && replay ? localTime(startMs) : start}
onChange={(value) => { playback.pause(); setStart(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
<DateTimeField
label="演示结束时间"
value={locked && replay ? localTime(endMs) : end}
onChange={(value) => { playback.pause(); setEnd(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
</div>
</div>
) : null}
<div className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>
{view !== null
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
: "拨动刻度只定位时间,不会自动触发监测"}
</span>
{view !== null ? (
<Button size="sm" variant="ghost" onClick={() => setView(null)}>回到监测时间</Button>
) : null}
</div>
<div className="space-y-2 border-t border-border p-3">
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
<ScadaTimeTuner
start={startMs}
end={endMs}
value={Math.max(startMs, Math.min(endMs, displayTime))}
step={TIMELINE_STEP_MS}
following={playback.started && view === null}
browsing={view !== null}
onInteract={playback.pause}
onChange={(value) => { playback.pause(); setView(value); }}
/>
) : null}
<div className="flex min-h-9 flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>{view !== null
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
: "拨动刻度只定位时间,不会自动触发监测"}</span>
<Button size="sm" variant="ghost" disabled={displayTime === startMs}
onClick={() => { playback.pause(); setView(startMs); }}>回到开始时间</Button>
</div>
<div role="group" aria-label="SCADA 播放控制" className="mx-auto grid w-full max-w-sm grid-cols-3 gap-2">
<Button size="sm" className="h-10 gap-1.5 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { setView(null); playback.play(); }}><Play size={14} />播放</Button>
<Button size="sm" variant="outline" className="h-10 gap-1.5 px-2" disabled={!playback.playing}
onClick={playback.pause}><Pause size={14} />暂停</Button>
<Button size="sm" variant="ghost" className="h-10 px-2" disabled={restartDisabled}
onClick={() => void restart()}>重新开始</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
size="sm"
className="h-10"
disabled={stepDisabled}
onClick={() => void advance()}
>
<SkipForward size={14} />
{replay?.step_pending
? "正在监测本步"
: locked ? "下一步并监测" : replay ? "开始新一轮监测" : "开始并监测"}
</Button>
{replay && ["ready", "paused", "failed"].includes(replay.status) ? (
<Button
size="sm"
variant="ghost"
className="h-10"
disabled={busy || !capabilities?.can_write}
onClick={() => void backend.command("stop")}
>
<Square size={13} />停止本轮
</Button>
) : null}
{replay?.status === "failed" ? (
<Button
size="sm"
variant="outline"
className="h-10"
disabled={busy || !capabilities?.can_write}
onClick={() => void backend.command("retry")}
>
<RotateCcw size={13} />重试本步
</Button>
) : null}
</div>
{replay ? (
<p role="status" aria-label="SCADA 监测进度" className="text-[11px] leading-5 text-muted-foreground tabular-nums">
当前时间 {clock(new Date(displayTime).toISOString())} · 已处理至 {processedDisplayTime === null ? "未开始" : clock(new Date(processedDisplayTime).toISOString())}
{replay.anomaly_count ? ` · 累计检出 ${replay.anomaly_count} 个异常事件` : ""}
</p>
) : null}
{replay?.last_monitoring && monitoring && MonitoringIcon ? (
<section
aria-label="本步监测结果"
role="status"
className={`rounded-lg px-3.5 py-3 ring-1 ring-inset ${monitoring.className}`}
>
<div className="flex items-start gap-2.5">
<MonitoringIcon className={`mt-0.5 shrink-0 ${monitoring.iconClassName}`} size={17} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h4 className="text-xs font-semibold">{monitoring.title}</h4>
<time className="text-[11px] tabular-nums" dateTime={replay.last_monitoring.period_end}>
{clock(replay.last_monitoring.period_end)}
</time>
</div>
<p className="mt-1 text-xs leading-5">{replay.last_monitoring.detail}</p>
{replay.last_monitoring.task_id ? (
<p className="mt-1 text-[11px] font-medium">已进入爆管调查与定位流程</p>
) : null}
<div className="space-y-2 border-t border-border bg-muted/30 p-3">
{selectionNeedsNewRound ? <p className="text-[11px] leading-5 text-muted-foreground">
监测此时刻将建立新一轮回放,原有调查记录保留。
</p> : null}
<div role="group" aria-label="SCADA 单步监测" className="grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" className="h-10 px-2" disabled={selectionDisabled}
onClick={() => void monitorSelection()}>
{monitoringSelection ? "正在监测" : "监测选定时刻"}
</Button>
<Button size="sm" variant="outline" className="h-10 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { playback.pause(); setView(null); void advance(); }}>
{!monitoringSelection && (replay?.step_pending || busy) ? "正在监测本步" : locked ? "下一步并监测" : "单步监测"}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="space-y-0.5 text-[11px] leading-5 text-muted-foreground tabular-nums">
<p role="status" aria-label="SCADA 监测进度">
已处理至 {matchingReplay && !restarting && processedDisplayTime !== null
? stamp(new Date(processedDisplayTime).toISOString()) : "未开始"}
</p>
{nextTime !== null ? <p>{matchingReplay?.status === "completed" && !restarting
? "本轮监测已完成" : `下次监测 ${clock(new Date(nextTime).toISOString())}`}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-1">
{replay && ["ready", "paused", "failed"].includes(replay.status) ? <Button
size="sm" variant="ghost" className="h-10 px-2" disabled={busy || !capabilities?.can_write}
onClick={() => { playback.pause(); void backend.command("stop"); }}><Square size={13} />停止本轮</Button> : null}
{replay?.status === "failed" ? <Button size="sm" variant="outline" className="h-10 px-2"
disabled={busy || !capabilities?.can_write} onClick={() => { playback.pause(); void backend.command("retry"); }}>
<RotateCcw size={13} />重试本步</Button> : null}
</div>
</div>
</section>
) : null}
</div>
</section>
<details className="border-t border-border pt-3">
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-medium text-foreground">
<ChevronDown size={13} />检测参数
<ChevronDown size={13} />检测规则
<span className="font-normal text-muted-foreground">
{selectedDevices.length} 个测点 · {rules.length} 项规则
{monitoringConfig ? "任一指标异常即调查" : `${selectedDevices.length} 个测点 · ${rules.length} 项基础规则`}
</span>
</summary>
<div className="mt-3 space-y-3">
{locked && replay ? (
<ul className="space-y-1 text-xs text-muted-foreground" aria-label="本轮固定规则">
{replay.rules.map((rule) => (
<li key={rule.rule_id}>
{rule.metric_name ?? typeLabel(replay.devices.find((device) => rule.device_ids.includes(device.device_id))?.device_type ?? rule.rule_id)}
{` · ${rule.device_ids.length} 个测点 · `}
{rule.kind === "bounds"
? `${rule.lower ?? "无下限"} ~ ${rule.upper ?? "无上限"} ${rule.unit}`
: `变化阈值 ${rule.threshold}%`}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{types.map((type) => (
<label key={type} className="flex min-h-10 items-center gap-2 text-xs">
<input
type="checkbox"
checked={selectedTypes.has(type)}
disabled={locked || busy}
onChange={(event) => setSelectedTypes((current) => {
const next = new Set(current);
if (event.target.checked) next.add(type);
else next.delete(type);
return next;
})}
/>
{typeLabel(type)}
</label>
))}
</div>
<label className="block text-xs text-muted-foreground">
压力下降/流量变化阈值(%)
<Input
aria-label="压力流量变化阈值"
className="mt-1 h-10 bg-card"
type="number"
min="0.1"
value={threshold}
disabled={locked || busy}
onChange={(event) => setThreshold(event.target.value)}
/>
</label>
{types.includes("quality") ? (
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground sm:col-span-2">
水质指标名称
<Input
aria-label="水质指标名称"
className="mt-1 h-10 bg-card"
value={qualityMetric}
onChange={(event) => setQualityMetric(event.target.value)}
disabled={locked || busy}
placeholder="例如:化学示踪物浓度"
/>
</label>
<label className="text-xs text-muted-foreground">
水质下限(设备单位)
<Input aria-label="水质下限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityLower} onChange={(event) => setQualityLower(event.target.value)} disabled={locked || busy} />
</label>
<label className="text-xs text-muted-foreground">
水质上限(设备单位)
<Input aria-label="水质上限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityUpper} onChange={(event) => setQualityUpper(event.target.value)} disabled={locked || busy} />
</label>
</div>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
原始值先存入 TSDB,再完成质量校验与清洗;异常检测使用清洗结果,同时保留原始值供追溯。
</p>
{!valid && !locked ? (
<p className="text-xs text-muted-foreground">
请选择不超过 31 天的有效区间、最多 500 个设备,并配置至少一项有效规则。
</p>
) : null}
{monitoringConfig ? <ScadaMonitoringRules
config={monitoringConfig}
calibration={rulesFrozen ? replay?.monitoring_calibration : null}
frozen={rulesFrozen}
/> : <p className="text-xs text-muted-foreground">任一已配置指标异常即启动调查。项目暂未返回组合监测配置。</p>}
</div>
</details>
</WorkbenchSection>
);
}
function Readout({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 px-2.5 py-2.5 sm:px-3">
<p className="truncate text-[10px] leading-4 text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-xs font-semibold text-foreground tabular-nums">{value}</p>
</div>
);
function stamp(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
function localTime(time: number) {
@@ -450,7 +444,3 @@ function clock(value?: string | null) {
? new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
: "未开始";
}
function typeLabel(value: string) {
return ({ pressure: "压力", pipe_flow: "流量", flow: "流量", quality: "水质" } as Record<string, string>)[value] ?? value;
}
@@ -8,11 +8,13 @@ import { ScadaReplaySection } from "./scada-replay-section";
export function ScadaSystemSection({
active,
getAccessToken,
refreshGeneration
refreshGeneration,
onOpenInvestigation
}: {
active: boolean;
getAccessToken?: WorkbenchAccess;
refreshGeneration: number;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null);
@@ -80,6 +82,7 @@ export function ScadaSystemSection({
active={active}
getAccessToken={getAccessToken}
catalog={catalog}
onOpenInvestigation={onOpenInvestigation}
/>
) : null}
</div>
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "motion/react";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
@@ -10,35 +10,61 @@ const ACCESSIBLE_FORMAT = new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
});
const DRAG_PUBLISH_MS = 120;
const SNAP_DURATION_SECONDS = 0.22;
const SNAP_EASE = [0.22, 1, 0.36, 1] as const;
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange }: {
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange, onInteract }: {
start: number; end: number; value: number; step: number; following: boolean; browsing?: boolean; onChange: (time: number) => void;
onInteract?: () => void;
}) {
const surface = useRef<HTMLDivElement>(null);
const dragging = useRef<{ x: number; time: number; pointerId: number } | null>(null);
const snapping = useRef(false);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const [isSnapping, setIsSnapping] = useState(false);
const [animationWindow, setAnimationWindow] = useState<{ from: number; to: number } | null>(null);
const [width, setWidth] = useState(500);
const reduced = useReducedMotion();
const selectedValue = snapTimelineTime(value, start, end, step);
const selectedValue = following ? Math.max(start, Math.min(end, value)) : snapTimelineTime(value, start, end, step);
const position = useMotionValue(selectedValue);
const animation = useRef<{ stop: () => void } | null>(null);
const animationTarget = useRef<number | null>(null);
const previewFrame = useRef<number | null>(null);
const publishTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onChangeRef = useRef(onChange);
const displayTime = previewTime ?? selectedValue;
const isDragging = previewTime !== null;
const isDragging = previewTime !== null && !isSnapping;
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
// Synchronize newly mounted ticks before paint when a monitoring step moves the dial.
useLayoutEffect(() => {
if (dragging.current) return;
animation.current?.stop();
if (following && !reduced) animation.current = animate(position, selectedValue, { duration: 0.85, ease: "linear" });
else position.set(selectedValue);
return () => animation.current?.stop();
if (snapping.current && animationTarget.current === selectedValue) return;
if (snapping.current) {
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
}
if (!following || reduced) {
position.set(selectedValue);
return;
}
const controls = animate(position, selectedValue, { duration: 0.12, ease: "linear" });
animation.current = controls;
return () => {
controls.stop();
if (animation.current === controls) animation.current = null;
};
}, [following, position, reduced, selectedValue]);
useEffect(() => () => {
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
dragging.current = null;
snapping.current = false;
animationTarget.current = null;
animation.current?.stop();
}, []);
useEffect(() => {
@@ -58,49 +84,109 @@ export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value,
const major = Math.max(1, Math.ceil((step % MINUTE ? 88 : 64) * span / width / step)) * step;
const fine = step / 5;
const lensRadius = Math.min(120, Math.max(56, width * 0.2));
const base = Math.floor(displayTime / fine) * fine;
const count = Math.ceil(span / fine / 2) + 2;
const renderStart = Math.min(displayTime, animationWindow?.from ?? displayTime, animationWindow?.to ?? displayTime);
const renderEnd = Math.max(displayTime, animationWindow?.from ?? displayTime, animationWindow?.to ?? displayTime);
const renderPadding = span / 2 + 2 * fine;
const markStart = Math.max(start, Math.floor((renderStart - renderPadding) / fine) * fine);
const markEnd = Math.min(end, Math.ceil((renderEnd + renderPadding) / fine) * fine);
const markCount = Math.max(1, Math.floor((markEnd - markStart) / fine) + 1);
const marks = useMemo(() => [...new Set([
...Array.from({ length: count * 2 + 1 }, (_, index) => base + (index - count) * fine),
...[start, end].filter((time) => Math.abs(time - base) <= span / 2 + 2 * fine)
...Array.from({ length: markCount }, (_, index) => markStart + index * fine),
...[start, end].filter((time) => time >= markStart && time <= markEnd)
])]
.filter((time) => time >= start && time <= end)
.map((time) => <ScadaTick key={time} time={time} width={width} span={span} major={major} minor={step}
endpoint={time === start || time === end} timeFormat={timeFormat}
lensRadius={lensRadius} position={position} />), [base, count, end, fine, lensRadius, major, position, span, start, step, timeFormat, width]);
lensRadius={lensRadius} position={position} />), [end, fine, lensRadius, major, markCount, markEnd, markStart, position, span, start, step, timeFormat, width]);
const clamp = (time: number) => Math.max(start, Math.min(end, time));
const snap = (time: number) => snapTimelineTime(time, start, end, step);
const animateTo = (finalTime: number) => {
const interruptedTarget = animationTarget.current;
animation.current?.stop();
if (interruptedTarget !== null) position.set(interruptedTarget);
const animationFrom = position.get();
setPreviewTime(finalTime);
if (reduced || Math.abs(animationFrom - finalTime) < 1) {
position.set(finalTime);
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
animation.current = null;
animationTarget.current = null;
onChangeRef.current(finalTime);
return;
}
snapping.current = true;
animationTarget.current = finalTime;
setAnimationWindow({ from: animationFrom, to: finalTime });
setIsSnapping(true);
const controls = animate(position, finalTime, {
duration: SNAP_DURATION_SECONDS,
ease: SNAP_EASE,
onComplete: () => {
if (animation.current !== controls) return;
position.set(finalTime);
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
animation.current = null;
animationTarget.current = null;
}
});
animation.current = controls;
onChangeRef.current(finalTime);
};
const moveImmediately = (finalTime: number) => {
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setAnimationWindow(null);
setPreviewTime(null);
position.set(finalTime);
onChangeRef.current(finalTime);
};
const finishDrag = (pointerId: number) => {
if (dragging.current?.pointerId !== pointerId) return;
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
previewFrame.current = publishTimer.current = null;
const finalTime = snap(position.get());
position.set(finalTime);
dragging.current = null;
setPreviewTime(null);
onChangeRef.current(finalTime);
animateTo(finalTime);
};
return (
<div className="select-none space-y-2">
<p className="text-center text-[11px] text-muted-foreground">{browsing || isDragging ? "时间定位" : "监测时间"}</p>
<div className="flex items-baseline justify-center gap-2.5 tabular-nums" aria-live="off">
<span className="text-xs text-muted-foreground">{DATE_FORMAT.format(displayTime)}</span>
<span className="text-3xl font-medium tracking-tight text-foreground">{timeFormat.format(displayTime)}</span>
<div className="flex min-h-9 items-center justify-between gap-3 tabular-nums" aria-live="off">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{browsing || isDragging || isSnapping ? "时间定位" : following ? "播放时间" : "监测时间"}</span>
<span>{DATE_FORMAT.format(displayTime)}</span>
</div>
<span className="text-2xl font-semibold tracking-tight text-foreground">{timeFormat.format(displayTime)}</span>
</div>
<div ref={surface} role="slider" tabIndex={0} aria-label="SCADA 调频时间轴"
data-dragging={isDragging}
data-snapping={isSnapping}
data-step-ms={step}
aria-description={`方向键每次移动 ${formatMonitoringInterval(step / 1000)},Home 和 End 到区间两端`}
aria-valuemin={start} aria-valuemax={end} aria-valuenow={displayTime}
aria-valuetext={ACCESSIBLE_FORMAT.format(displayTime)}
className="scada-fm-window relative h-28 touch-pan-y overflow-hidden rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
className="scada-fm-window relative h-28 touch-pan-y overflow-hidden rounded-xl outline-hidden"
onPointerDown={(event) => {
if (event.button !== 0) return;
onInteract?.();
event.stopPropagation();
if (dragging.current) return;
if (event.pointerType === "mouse") event.preventDefault();
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setAnimationWindow(null);
dragging.current = { x: event.clientX, time: position.get(), pointerId: event.pointerId };
setPreviewTime(snap(position.get()));
event.currentTarget.setPointerCapture(event.pointerId);
@@ -129,16 +215,16 @@ export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value,
: key === "PageUp" ? moveTimelineTime(displayTime, start, end, step, major / step)
: key === "PageDown" ? moveTimelineTime(displayTime, start, end, step, -major / step) : null;
if (next !== null) {
onInteract?.();
event.preventDefault(); event.stopPropagation();
animation.current?.stop();
position.set(next);
onChange(next);
if (key.startsWith("Arrow")) animateTo(next);
else moveImmediately(next);
}
}}>
<div aria-hidden="true" className="scada-fm-scale pointer-events-none absolute inset-0">
{marks}
</div>
<span aria-hidden="true" className="scada-fm-needle pointer-events-none absolute bottom-12 left-1/2 top-3 w-0.5 -translate-x-1/2" />
<span aria-hidden="true" className="scada-fm-needle pointer-events-none absolute left-1/2 top-1.5 h-16 w-2.5 -translate-x-1/2" />
</div>
</div>
);
@@ -0,0 +1,85 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventGpsCoordinate } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
const first: EventGpsCoordinate = [121.45, 30.93];
const second: EventGpsCoordinate = [121.46, 30.94];
function deferred() {
let resolve!: (address: string) => void;
const promise = new Promise<string>((done) => { resolve = done; });
return { promise, resolve };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("event location address lookup", () => {
it("debounces adjustments and prevents stale addresses from replacing a newer point", async () => {
const older = deferred();
const newer = deferred();
const resolveAddress = vi.fn<EventLocationAddressResolver>()
.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
const onChange = vi.fn();
const { result, rerender } = renderHook(useEventLocationAddress, {
initialProps: { coordinate: first, resolveAddress, onChange }
});
expect(result.current.pending).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(250));
const firstSignal = resolveAddress.mock.calls[0][1];
rerender({ coordinate: second, resolveAddress, onChange });
expect(firstSignal.aborted).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(250));
await act(async () => newer.resolve("新地址"));
await act(async () => older.resolve("旧地址"));
expect(onChange).toHaveBeenLastCalledWith("新地址");
expect(onChange).not.toHaveBeenCalledWith("旧地址");
expect(result.current.pending).toBe(false);
});
it("keeps a manual correction even if the address request finishes afterwards", async () => {
const request = deferred();
const resolveAddress = vi.fn<EventLocationAddressResolver>().mockReturnValue(request.promise);
const onChange = vi.fn();
const { result } = renderHook(() => useEventLocationAddress({ coordinate: first, resolveAddress, onChange }));
await act(() => vi.advanceTimersByTimeAsync(250));
act(() => result.current.changeLocation("现场确认地址"));
await act(async () => request.resolve("服务返回地址"));
expect(resolveAddress.mock.calls[0][1].aborted).toBe(true);
expect(onChange).toHaveBeenLastCalledWith("现场确认地址");
expect(result.current.pending).toBe(false);
});
it("cancels lookup when the editor closes and only queries the final keyboard adjustment", async () => {
const resolveAddress = vi.fn<EventLocationAddressResolver>().mockResolvedValue("地址");
const onChange = vi.fn();
const { result, rerender } = renderHook(useEventLocationAddress, {
initialProps: { active: true, coordinate: first, resolveAddress, onChange }
});
rerender({ active: true, coordinate: second, resolveAddress, onChange });
await act(() => vi.advanceTimersByTimeAsync(250));
expect(resolveAddress).toHaveBeenCalledOnce();
expect(resolveAddress.mock.calls[0][0]).toEqual(second);
rerender({ active: true, coordinate: first, resolveAddress, onChange });
rerender({ active: false, coordinate: first, resolveAddress, onChange });
await act(() => vi.advanceTimersByTimeAsync(250));
expect(resolveAddress).toHaveBeenCalledOnce();
expect(result.current.pending).toBe(false);
});
it("allows retry after a failed query without changing the selected GPS coordinate", async () => {
const resolveAddress = vi.fn<EventLocationAddressResolver>()
.mockRejectedValueOnce(new Error("地址服务暂不可用")).mockResolvedValueOnce("恢复后的地址");
const onChange = vi.fn();
const { result } = renderHook(() => useEventLocationAddress({ coordinate: first, resolveAddress, onChange }));
await act(() => vi.advanceTimersByTimeAsync(250));
expect(result.current.error).toBe("地址服务暂不可用");
expect(result.current.pending).toBe(false);
act(() => result.current.retry());
await act(() => vi.advanceTimersByTimeAsync(250));
expect(result.current.error).toBeNull();
expect(onChange).toHaveBeenLastCalledWith("恢复后的地址");
expect(resolveAddress.mock.calls[1][0]).toEqual(first);
});
});
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EventGpsCoordinate } from "./event-location-coordinate";
export type EventLocationAddressResolver = (coordinate: EventGpsCoordinate, signal: AbortSignal) => Promise<string>;
export type EventLocationAddressLookup = {
pending: boolean;
error: string | null;
changeLocation: (value: string) => void;
retry: () => void;
};
export function useEventLocationAddress({ active = true, coordinate, resolveAddress, onChange }: {
active?: boolean;
coordinate: EventGpsCoordinate | null;
resolveAddress: EventLocationAddressResolver;
onChange: (value: string) => void;
}): EventLocationAddressLookup {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const longitude = coordinate?.[0];
const latitude = coordinate?.[1];
useEffect(() => {
setError(null);
if (!active || longitude === undefined || latitude === undefined) {
setPending(false);
return;
}
const controller = new AbortController();
requestRef.current = controller;
setPending(true);
onChange("");
// Coalesce keyboard adjustments. GPS stays immediate while only the final
// point is reverse-geocoded; a former point's address must never be reused.
const timer = window.setTimeout(async () => {
if (controller.signal.aborted) return;
try {
const address = await resolveAddress([longitude, latitude], controller.signal);
if (!controller.signal.aborted) onChange(address);
} catch (failure) {
if (!controller.signal.aborted) {
setError(failure instanceof Error ? failure.message : "地址查询失败,请重试或手动填写");
}
} finally {
if (!controller.signal.aborted) setPending(false);
}
}, 250);
return () => {
window.clearTimeout(timer);
controller.abort();
if (requestRef.current === controller) requestRef.current = null;
};
}, [active, longitude, latitude, resolveAddress, onChange, retryGeneration]);
const changeLocation = useCallback((value: string) => {
requestRef.current?.abort();
setPending(false);
setError(null);
onChange(value);
}, [onChange]);
return { pending, error, changeLocation, retry: () => setRetryGeneration((current) => current + 1) };
}
@@ -0,0 +1,144 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ScadaReplay } from "../api/scada-replay-client";
import { useScadaPlayback } from "./use-scada-playback";
const start = Date.parse("2026-07-16T07:30:00+08:00");
const end = start + 3600_000;
function receipt(time: number, status = "paused") {
return { virtual_now: new Date(time).toISOString(), status, step_pending: false } as ScadaReplay;
}
function setup(advance = vi.fn<() => Promise<ScadaReplay | null>>()) {
const options = { active: true, enabled: true, busy: false, start, end, now: start,
intervalSeconds: 900, restart: false, advance };
return { ...renderHook((props) => useScadaPlayback(props), { initialProps: options }), options, advance };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("advances the display first and monitors once at each project boundary until the end", async () => {
let count = 0;
const advance = vi.fn(async () => {
count += 1;
return receipt(start + count * 900_000, count === 4 ? "completed" : "paused");
});
const { result } = setup(advance);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(result.current.time).toBe(start + 450_000);
expect(advance).not.toHaveBeenCalled();
await act(() => vi.advanceTimersByTimeAsync(17500));
expect(advance).toHaveBeenCalledTimes(4);
expect(result.current.time).toBe(end);
expect(result.current.playing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).toHaveBeenCalledTimes(4);
});
it("waits for a slow diagnosis without overlapping requests or jumping ahead", async () => {
let resolve!: (value: ScadaReplay) => void;
const advance = vi.fn(() => new Promise<ScadaReplay>((done) => { resolve = done; }));
const { result } = setup(advance);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(20000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.time).toBe(start + 900_000);
act(() => result.current.pause());
await act(async () => resolve(receipt(start + 900_000)));
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.playing).toBe(false);
});
it("pauses when the panel becomes inactive and resumes from the same display time", async () => {
const { result, rerender, options, advance } = setup();
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.newRound).toBe(false);
const pausedAt = result.current.time;
rerender({ ...options, active: false });
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(result.current.time).toBe(pausedAt);
expect(result.current.playing).toBe(false);
expect(advance).not.toHaveBeenCalled();
rerender(options);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(pausedAt + 180_000);
});
it("pauses on a failed command and does not retry automatically", async () => {
const { result, advance } = setup(vi.fn(async () => null));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(15000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.playing).toBe(false);
});
it("uses the configured monitoring interval independently of the dial's five-minute ticks", async () => {
const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 300_000)));
rerender({ ...options, intervalSeconds: 300 });
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(1700));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.time).toBe(start + 300_000);
});
it("pauses for a hidden page and cleans up its automatic monitor on unmount", async () => {
const { result, unmount, advance } = setup();
act(() => result.current.play());
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
act(() => document.dispatchEvent(new Event("visibilitychange")));
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(result.current.playing).toBe(false);
visibility.mockRestore();
act(() => result.current.play());
unmount();
await vi.advanceTimersByTimeAsync(10000);
expect(advance).not.toHaveBeenCalled();
});
it("resumes a restarted round before its first request without resetting the clock again", async () => {
const { result, rerender, options, advance } = setup();
rerender({ ...options, now: end, restart: true });
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.newRound).toBe(true);
const pausedAt = result.current.time;
act(() => result.current.pause());
act(() => result.current.play());
expect(result.current.time).toBe(pausedAt);
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(pausedAt + 180_000);
expect(advance).not.toHaveBeenCalled();
});
it("keeps playing when the refreshed snapshot arrives before the command promise resolves", async () => {
let resolve!: (value: ScadaReplay) => void;
const { result, rerender, options } = setup(vi.fn(() => new Promise<ScadaReplay>((done) => { resolve = done; })));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(5000));
rerender({ ...options, now: start + 900_000 });
await act(async () => resolve(receipt(start + 900_000)));
expect(result.current.playing).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(start + 1080_000);
});
it("resets to a paused beginning without processing until play is pressed again", async () => {
const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 900_000)));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.time).toBe(start + 360_000);
act(() => result.current.reset());
expect(result.current.time).toBe(start);
expect(result.current.playing).toBe(false);
expect(result.current.started).toBe(false);
expect(result.current.newRound).toBe(false);
rerender(options);
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).not.toHaveBeenCalled();
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(5000));
expect(advance).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import type { ScadaReplay } from "../api/scada-replay-client";
export const SCADA_PLAYBACK_SPEED = 180;
interface PlaybackOptions {
active: boolean;
enabled: boolean;
busy: boolean;
start: number;
end: number;
now: number;
intervalSeconds: number | null | undefined;
restart: boolean;
advance: () => Promise<ScadaReplay | null>;
}
/** Browser-owned demo clock. Each boundary waits for one durable Server step. */
export function useScadaPlayback(options: PlaybackOptions) {
const latest = useRef(options);
latest.current = options;
const [playing, setPlaying] = useState(false);
const [started, setStarted] = useState(false);
const [newRound, setNewRound] = useState(false);
const [time, setTime] = useState(options.now);
const running = useRef(false);
const pending = useRef(false);
const position = useRef(options.now);
const processed = useRef(options.now);
const mounted = useRef(true);
const restartPrepared = useRef(false);
const pause = () => { running.current = false; setPlaying(false); };
const reset = () => {
pause();
setStarted(false);
setNewRound(false);
restartPrepared.current = false;
processed.current = position.current = latest.current.start;
setTime(latest.current.start);
};
useEffect(() => {
mounted.current = true;
const hidden = () => { if (document.visibilityState !== "visible") pause(); };
document.addEventListener("visibilitychange", hidden);
return () => {
mounted.current = false;
running.current = false;
document.removeEventListener("visibilitychange", hidden);
};
}, []);
useEffect(() => {
pause();
setStarted(false);
setNewRound(false);
position.current = latest.current.now;
processed.current = latest.current.now;
setTime(latest.current.now);
}, [options.start, options.end]);
useEffect(() => {
processed.current = options.now;
position.current = options.now;
setTime(options.now);
if (options.now >= latest.current.end) pause();
}, [options.now]);
useEffect(() => {
if (!options.active || !options.enabled || options.restart) pause();
}, [options.active, options.enabled, options.restart]);
useEffect(() => { restartPrepared.current = false; }, [options.restart]);
useEffect(() => {
if (!playing) return;
let previousTick = performance.now();
const timer = window.setInterval(() => {
const current = latest.current;
const tick = performance.now();
const elapsed = Math.min(tick - previousTick, 250);
previousTick = tick;
if (!running.current || !current.active || !current.enabled || document.visibilityState !== "visible"
|| pending.current || current.busy || !current.intervalSeconds) return;
const interval = current.intervalSeconds * 1000;
const target = Math.min(current.end, current.start
+ (Math.floor((processed.current - current.start) / interval) + 1) * interval);
position.current = Math.min(target, position.current + elapsed * SCADA_PLAYBACK_SPEED);
setTime(position.current);
if (position.current < target) return;
pending.current = true;
const requestedFrom = processed.current;
void current.advance().then((next) => {
if (!mounted.current) return;
if (!next || next.status === "failed" || next.step_pending) {
pause();
return;
}
const nextTime = Date.parse(next.virtual_now);
if (nextTime <= requestedFrom && !current.restart) {
pause();
return;
}
processed.current = position.current = nextTime;
setNewRound(false);
setTime(nextTime);
if (next.status === "completed" || nextTime >= current.end) pause();
}).catch(() => { if (mounted.current) pause(); }).finally(() => {
pending.current = false;
previousTick = performance.now();
});
}, 100);
return () => window.clearInterval(timer);
}, [playing]);
const play = () => {
if (!options.active || !options.enabled || options.busy || pending.current || !options.intervalSeconds) return;
if ((options.restart && !restartPrepared.current) || position.current >= options.end) {
processed.current = position.current = options.start;
restartPrepared.current = true;
setNewRound(true);
setTime(options.start);
}
running.current = true;
setStarted(true);
setPlaying(true);
};
return { playing, started, newRound, time, play, pause, reset };
}
@@ -50,21 +50,25 @@ export function useScadaReplay(active: boolean, access?: WorkbenchAccess) {
}, [active, refresh]);
const change = async (operation: () => Promise<ScadaReplay>) => {
if (mutation.current) return;
if (mutation.current) return null;
mutation.current = true;
++refreshSequence.current;
setBusy(true); setError(null);
try {
const next = await operation();
setReplay((previous) => reuseReplayProjection(previous, next));
return next;
}
catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "回放操作失败");
return null;
}
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "回放操作失败"); }
finally { mutation.current = false; setBusy(false); }
};
return {
client, capabilities, replay, error, busy, refresh, change,
command: (action: ScadaReplayAction) => {
if (!replay) return Promise.resolve();
if (!replay) return Promise.resolve(null);
return change(() => client.command(replay, action));
}
};