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