import { useEffect, useMemo, useRef, useState } from "react"; import { Activity, ChevronDown, RotateCcw, Square, Pause, Play } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { DateTimeField } from "@/shared/ui/date-time-field"; 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 { 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 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 = { ready: "待监测", paused: "等待下一步", completed: "区间已完成", stopped: "本轮已停止", failed: "处理失败" }; export function ScadaReplaySection({ active, catalog, 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(CASE3_START)); const [end, setEnd] = useState(() => localTime(CASE3_END)); const [threshold, setThreshold] = useState("30"); const [qualityMetric, setQualityMetric] = useState(""); const [qualityLower, setQualityLower] = useState(""); const [qualityUpper, setQualityUpper] = useState(""); const [selectedTypes, setSelectedTypes] = useState(() => new Set( catalog.items.map((item) => item.device_type) )); const [view, setView] = useState(null); const restoredId = useRef(undefined); const announced = useRef(new Set()); const [animationId, setAnimationId] = useState(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 startMs = locked && replay ? Date.parse(replay.start_time) : Date.parse(start); const endMs = locked && replay ? Date.parse(replay.end_time) : Date.parse(end); 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 selectedDevices = catalog.items.filter((item) => selectedTypes.has(item.device_type)); const rules = useMemo(() => { const groups = new Map(); for (const item of catalog.items) { if (!selectedTypes.has(item.device_type)) continue; const key = `${item.device_type}/${item.measurement_unit}/${item.transmission_frequency}`; groups.set(key, [...groups.get(key) ?? [], item]); } const result: ScadaReplayRule[] = []; for (const devices of groups.values()) { const device = devices[0]; if (!device.measurement_unit) continue; if (["pressure", "pipe_flow", "flow"].includes(device.device_type) && Number(threshold) > 0) { result.push({ rule_id: `change_${result.length}`, device_ids: devices.map((item) => item.device_id), kind: "relative_change", direction: device.device_type === "pressure" ? "decrease" : "both", threshold: Number(threshold), unit: device.measurement_unit, minimum_samples: 3, window_samples: 4, consecutive_samples: 1, recovery_samples: 2, minimum_devices: 1 }); } if (device.device_type === "quality" && qualityMetric.trim() && (qualityLower.trim() || qualityUpper.trim())) { result.push({ rule_id: `quality_${result.length}`, device_ids: devices.map((item) => item.device_id), kind: "bounds", unit: device.measurement_unit, metric_name: qualityMetric.trim(), lower: qualityLower.trim() ? Number(qualityLower) : null, upper: qualityUpper.trim() ? Number(qualityUpper) : null, consecutive_samples: 1, recovery_samples: 2, minimum_devices: 1 }); } } return result; }, [catalog, selectedTypes, threshold, qualityMetric, qualityLower, qualityUpper]); const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs && endMs - startMs <= 31 * 24 * 60 * 60_000 && selectedDevices.length > 0 && selectedDevices.length <= 500 && Boolean(interval) && rules.length > 0 && rules.every((rule) => [rule.threshold, rule.lower, rule.upper].every( (number) => number == null || Number.isFinite(number) ) && (rule.lower == null || rule.upper == null || rule.lower < rule.upper)); 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))); setSelectedTypes(new Set(replay.devices.map((device) => device.device_type))); const relative = replay.rules.find((rule) => rule.kind === "relative_change"); if (relative?.threshold != null) setThreshold(String(relative.threshold)); const water = replay.rules.find((rule) => rule.kind === "bounds" && rule.metric_name); setQualityMetric(water?.metric_name ?? ""); setQualityLower(water?.lower == null ? "" : String(water.lower)); setQualityUpper(water?.upper == null ? "" : String(water.upper)); }, [replay]); 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); } } }; const advance = async (): Promise => { 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"); }); } 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 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 ( } tone="teal" bodyClassName="space-y-3" > {capabilities && !capabilities.available ? ( {capabilities.reason} ) : null} {backend.error || matchingReplay?.error ? ( {backend.error ?? matchingReplay?.error} ) : null} {obsoleteCalibration ? ( 旧版判断已停用,请重新开始本轮,以使用历史同期正常范围。 ) : null} {capabilities && !interval ? ( {locked ? "本轮未固定监测频率,请停止后重新建立演示。" : "请先在项目配置中设置监测频率。"} ) : null} {capabilities && interval && !capabilities.burst_monitoring_ready ? ( {capabilities.burst_monitoring_reason};仍可演示读取、清洗和基础规则检测。 ) : null} startMs ? Math.max(0, Math.min(1, ((playback.started ? playback.time : now) - startMs) / (endMs - startMs))) : 0} /> { playback.pause(); onOpenInvestigation(taskId); } : undefined} />
Case3 · 事件 07/16 08:00 · 北京时间 {case3Range ? 事件前后各 30 分钟 : }
{ playback.pause(); setStart(value); }} minuteStep={5} disabled={locked || busy || playback.playing} contentClassName="workbench-shadcn-surface" /> { playback.pause(); setEnd(value); }} minuteStep={5} disabled={locked || busy || playback.playing} contentClassName="workbench-shadcn-surface" />
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? ( { playback.pause(); setView(value); }} /> ) : null}
{view !== null ? future ? "该时间尚未完成监测" : "已定位到历史监测时间" : "拨动刻度只定位时间,不会自动触发监测"}
{selectionNeedsNewRound ?

监测此时刻将建立新一轮回放,原有调查记录保留。

: null}

已处理至 {matchingReplay && !restarting && processedDisplayTime !== null ? stamp(new Date(processedDisplayTime).toISOString()) : "未开始"}

{nextTime !== null ?

{matchingReplay?.status === "completed" && !restarting ? "本轮监测已完成" : `下次监测 ${clock(new Date(nextTime).toISOString())}`}

: null}
{replay && ["ready", "paused", "failed"].includes(replay.status) ? : null} {replay?.status === "failed" ? : null}
检测规则 {monitoringConfig ? "任一指标异常即调查" : `${selectedDevices.length} 个测点 · ${rules.length} 项基础规则`}
{monitoringConfig ? :

任一已配置指标异常即启动调查。项目暂未返回组合监测配置。

}
); } 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) { const date = new Date(time); return new Date(time - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); } function clock(value?: string | null) { return value ? new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }) : "未开始"; }