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
@@ -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;
}