feat(scada): add manual monitoring timeline
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m12s

This commit is contained in:
2026-11-04 14:27:08 +08:00
parent 78481903d7
commit 0801e161ec
22 changed files with 1314 additions and 664 deletions
@@ -12,7 +12,6 @@ import { WorkbenchNotice } from "../components/workbench-panel-ui";
import {
PeripheralSampleClient,
type PeripheralContext,
type PeripheralTestDataRecord,
type PeripheralWorkOrder
} from "./client";
@@ -22,14 +21,12 @@ export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
onAgentTrigger,
onScadaAnomalyTrigger
onAgentTrigger
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -72,7 +69,7 @@ export function PeripheralSamplePanel({
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 最新数据</p>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 时段演示</p>
</div>
<Button
size="icon"
@@ -171,11 +168,6 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"}
getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration}
peripheralClient={client}
canSimulate={Boolean(
context?.can_write && context.managed_resources.test_data === "available"
)}
onAgentTrigger={onScadaAnomalyTrigger}
/>
</TabsContent>
</Tabs>
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PeripheralTestDataRecord } from "./client";
import { buildScadaAnomalyInvestigation } from "./scada-anomaly-agent-trigger";
describe("SCADA anomaly Agent trigger", () => {
it("keeps the persisted synthetic reading as the external task source", () => {
const record = {
record_id: "50000000-0000-4000-8000-000000000017",
revision: 3,
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
notes: "[SCADA_ANOMALY] 压力突降"
} as PeripheralTestDataRecord;
expect(buildScadaAnomalyInvestigation(record)).toMatchObject({
kind: "scada_anomaly",
title: "SCADA 异常调查 · PRESSURE-17",
summary: expect.stringContaining("0.08 MPa"),
occurredAt: "2026-10-22T02:03:00Z",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: 3,
event_kind: "scada_anomaly"
},
triggerKind: "event"
});
});
});
@@ -1,27 +0,0 @@
import type { CreateInvestigationInput } from "../hooks/use-operational-tasks";
import type { PeripheralTestDataRecord } from "./client";
export function buildScadaAnomalyInvestigation(
record: PeripheralTestDataRecord
): CreateInvestigationInput {
const valueLabel = record.value === null
? "无有效读数"
: `${record.value}${record.unit ? ` ${record.unit}` : ""}`;
const anomalyDetail = record.notes.replace("[SCADA_ANOMALY]", "").trim();
return {
kind: "scada_anomaly",
title: `SCADA 异常调查 · ${record.point_id}`,
summary: `${record.point_id} 在 ${record.observed_at} 记录模拟异常值 ${valueLabel}。${anomalyDetail}`,
detail: `由外围 SCADA 合成读数触发,点位 ${record.point_id},读数 ${valueLabel},发生时间 ${record.observed_at}。`,
occurredAt: record.observed_at,
priority: "normal",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: record.revision,
event_kind: "scada_anomaly"
},
triggerKind: "event"
};
}
@@ -0,0 +1,18 @@
import type { ScadaReplay } from "../api/scada-replay-client";
/** Keep projection identity when polling only advances the replay clock. */
export function reuseScadaReadings(previous: ScadaReplay["latest"] | null | undefined, next: ScadaReplay["latest"]): ScadaReplay["latest"] {
if (previous?.length !== next.length) return next;
const unchanged = next.every((row, index) => {
const old = previous[index];
return old.device_id === row.device_id && old.time === row.time && old.quality === row.quality
&& old.reason === row.reason && Object.is(old.monitored_value, row.monitored_value)
&& Object.is(old.cleaned_value, row.cleaned_value);
});
return unchanged ? previous : next;
}
export function reuseReplayProjection(previous: ScadaReplay | null, next: ScadaReplay | null): ScadaReplay | null {
if (!next || previous?.replay_id !== next.replay_id) return next;
return { ...next, latest: reuseScadaReadings(previous.latest, next.latest) };
}
@@ -0,0 +1,172 @@
import { cleanup, fireEvent, render, screen } 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 { reuseReplayProjection, reuseScadaReadings } from "./scada-replay-readings";
import { ScadaReplaySection } from "./scada-replay-section";
const useReplay = vi.hoisted(() => vi.fn());
vi.mock("./use-scada-replay", () => ({ useScadaReplay: useReplay }));
vi.mock("./scada-time-tuner", () => ({
ScadaTimeTuner: ({ value, step, onChange }: { value: number; step: number; onChange: (time: number) => void }) => (
<input
aria-label="回看位置测试输入"
type="number"
data-step-ms={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
)
}));
const catalog: LatestScadaReadings = {
items: [{
device_id: "P1",
device_type: "pressure",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: null,
monitored_value: null,
cleaned_value: null
}],
total: 1,
limit: 500,
offset: 0,
observed_count: 0,
latest_observed_at: "2026-10-22T01:00:00Z"
};
function replay(): ScadaReplay {
return {
replay_id: "60000000-0000-4000-8000-000000000001",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
status: "paused",
start_time: "2026-10-22T00:00:00Z",
end_time: "2026-10-22T02:00:00Z",
virtual_now: "2026-10-22T01:00:00Z",
delivered_until: "2026-10-22T01:00:00Z",
processed_until: "2026-10-22T01:00:00Z",
processed_count: 1,
anomaly_count: 0,
devices: catalog.items,
rules: [],
monitoring_interval_seconds: 900,
step_pending: false,
monitoring_count: 0,
last_monitoring: null,
latest: [{
time: "2026-10-22T00:15:00Z",
device_id: "P1",
monitored_value: 0,
cleaned_value: 0,
quality: "valid",
reason: "可信零读数"
}],
active_events: [],
error: null,
created_at: "2026-10-22T00:00:00Z",
updated_at: "2026-10-22T01:00:00Z"
};
}
describe("SCADA replay panel", () => {
let current: ScadaReplay;
let command: ReturnType<typeof vi.fn>;
let observations: ReturnType<typeof vi.fn>;
beforeEach(() => {
current = replay();
command = vi.fn();
observations = vi.fn();
useReplay.mockImplementation(() => ({
replay: current,
capabilities: {
available: true,
can_write: true,
monitoring_interval_seconds: 300,
burst_monitoring_ready: true,
burst_monitoring_reason: null
},
busy: false,
error: null,
events: [],
client: { observations },
command,
change: vi.fn()
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("changes only the timeline position while browsing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), {
target: { value: Date.parse("2026-10-22T00:45:00Z") }
});
expect(screen.getByText("已定位到历史监测时间")).toBeVisible();
expect(observations).not.toHaveBeenCalled();
expect(command).not.toHaveBeenCalled();
});
it("retains new evidence and separates otherwise identical replay runs", () => {
const incoming = structuredClone(current.latest);
expect(reuseScadaReadings(current.latest, incoming)).toBe(current.latest);
const changed = [{ ...incoming[0], reason: "仪表量程待核验" }];
expect(reuseScadaReadings(current.latest, changed)).toBe(changed);
const other = {
...current,
replay_id: "60000000-0000-4000-8000-000000000002",
latest: incoming
};
expect(reuseReplayProjection(current, other)?.latest).toBe(incoming);
});
it("keeps five-minute timeline timestamps separate from the monitoring period", () => {
current.step_pending = true;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveAttribute("data-step-ms", "300000");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("15 分钟");
expect(screen.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
});
it("actively triggers the next monitoring period", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "下一步并监测" }));
expect(command).toHaveBeenCalledWith("step");
});
it("shows the persisted monitoring decision and linked burst 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-v1",
status: "suspected_burst",
detail: "爆管侦测、压力下降与出厂边界流量增加相互支持",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: {},
outlet_flow: {},
burst_detection: {}
};
current.monitoring_count = 1;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本周期疑似爆管")).toBeVisible();
expect(screen.getByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).toBeVisible();
expect(screen.getByText("已进入爆管调查与定位流程")).toBeVisible();
});
});
@@ -0,0 +1,456 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
CheckCircle2,
ChevronDown,
CircleHelp,
RotateCcw,
SkipForward,
Square,
TriangleAlert
} 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 { ScadaTimeTuner } from "./scada-time-tuner";
import { useScadaReplay } from "./use-scada-replay";
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 STATUS: Record<ScadaReplay["status"], string> = {
ready: "待监测",
paused: "等待下一步",
completed: "区间已完成",
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
}: {
active: boolean;
catalog: LatestScadaReadings;
getAccessToken?: WorkbenchAccess;
}) {
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 [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<number | null>(null);
const restoredId = useRef<string | undefined>(undefined);
const locked = Boolean(replay && ACTIVE.has(replay.status));
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 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>();
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;
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 advance = () => {
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
return backend.command("step");
}
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
});
return backend.client.command(created, "step");
});
};
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));
return (
<WorkbenchSection
title="SCADA 监测演示"
description="时间轴按 5 分钟定位,监测按钮按项目周期触发异常分析。"
icon={<Activity size={15} />}
tone="teal"
bodyClassName="space-y-4"
>
{capabilities && !capabilities.available ? (
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
{capabilities.reason}
</WorkbenchNotice>
) : null}
{backend.error || replay?.error ? (
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
{backend.error ?? replay?.error}
</WorkbenchNotice>
) : null}
{capabilities && !interval ? (
<WorkbenchNotice tone="warning" title="监测频率未配置">
{locked ? "本轮未固定监测频率,请停止后重新建立演示。" : "请先在项目配置中设置监测频率。"}
</WorkbenchNotice>
) : null}
{capabilities && interval && !capabilities.burst_monitoring_ready ? (
<WorkbenchNotice tone="neutral" title="爆管组合监测尚未就绪">
{capabilities.burst_monitoring_reason};仍可演示读取、清洗和基础规则检测。
</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>
<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>
{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}
/>
</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="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>
</div>
</section>
) : null}
<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} />检测参数
<span className="font-normal text-muted-foreground">
{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}
</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 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 })
: "未开始";
}
function typeLabel(value: string) {
return ({ pressure: "压力", pipe_flow: "流量", flow: "流量", quality: "水质" } as Record<string, string>)[value] ?? value;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
const minute = 60_000;
const start = Date.parse("2026-10-08T08:07:00+08:00");
describe("SCADA timeline timestamps", () => {
it("quantizes continuous pointer positions to five-minute SCADA timestamps", () => {
const end = start + 60 * minute;
expect(snapTimelineTime(Date.parse("2026-10-08T08:26:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:25:00+08:00"));
expect(snapTimelineTime(Date.parse("2026-10-08T08:28:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:30:00+08:00"));
});
it("moves keyboard controls between clock-aligned five-minute timestamps", () => {
const end = start + 60 * minute;
expect(moveTimelineTime(start, start, end, 5 * minute, 1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
expect(moveTimelineTime(start + 6 * minute, start, end, 5 * minute, -1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
});
it("reaches exact range endpoints and never leaves the selected range", () => {
const end = start + 22 * minute;
expect(moveTimelineTime(end - minute, start, end, 5 * minute, 1)).toBe(end);
expect(moveTimelineTime(start + minute, start, end, 5 * minute, -1)).toBe(start);
});
it("formats the independent project monitoring period", () => {
expect(formatMonitoringInterval(900)).toBe("15 分钟");
expect(formatMonitoringInterval(91)).toBe("91 秒");
});
});
@@ -0,0 +1,17 @@
/** Return the nearest clock-aligned SCADA timestamp inside the selected range. */
export function snapTimelineTime(time: number, start: number, end: number, step: number): number {
return Math.max(start, Math.min(end, Math.round(time / step) * step));
}
/** Keyboard movement follows clock-aligned timestamps. */
export function moveTimelineTime(time: number, start: number, end: number, step: number, slots: number): number {
const slot = (slots > 0 ? Math.floor(time / step) : Math.ceil(time / step)) + slots;
return Math.max(start, Math.min(end, slot * step));
}
export function formatMonitoringInterval(seconds: number): string {
for (const [unit, label] of [[86400, "天"], [3600, "小时"], [60, "分钟"]] as const) {
if (seconds % unit === 0) return `${seconds / unit} ${label}`;
}
return `${seconds} 秒`;
}
@@ -1,16 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
import { ScadaSystemSection } from "./scada-system-section";
vi.mock("./scada-replay-section", () => ({
ScadaReplaySection: ({ catalog }: { catalog: { items: unknown[] } }) => (
<section data-testid="scada-monitoring-console" data-device-count={catalog.items.length}>
SCADA 监测演示
</section>
)
}));
describe("SCADA peripheral system", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("persists a synthetic abnormal reading before triggering Agent handling", async () => {
it("loads the complete catalog for monitoring without rendering a device list", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -38,55 +44,10 @@ describe("SCADA peripheral system", () => {
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const record: PeripheralTestDataRecord = {
record_id: "50000000-0000-4000-8000-000000000017",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
label: "SCADA 异常模拟 · PRESSURE-17",
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降",
origin: "synthetic",
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
updated_by: "tester"
};
const createTestData = vi.fn().mockResolvedValue(record);
const peripheralClient = {
listTestData: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
createTestData
} as unknown as PeripheralSampleClient;
const onAgentTrigger = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<ScadaSystemSection active refreshGeneration={0} />);
render(
<ScadaSystemSection
active
refreshGeneration={0}
peripheralClient={peripheralClient}
canSimulate
onAgentTrigger={onAgentTrigger}
/>
);
await screen.findByText("PRESSURE-17", { exact: true });
await user.click(screen.getByRole("button", { name: "设定读数" }));
await user.type(screen.getByPlaceholderText("输入需要演练的异常值"), "0.08");
await user.type(screen.getByPlaceholderText("例如:压力突降,偏离近一小时稳定区间"), "压力突降");
await user.click(screen.getByRole("button", { name: "保存并触发 Agent" }));
await waitFor(() => expect(createTestData).toHaveBeenCalledOnce());
expect(createTestData).toHaveBeenCalledWith(expect.objectContaining({
point_id: "PRESSURE-17",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降"
}));
expect(onAgentTrigger).toHaveBeenCalledWith(record);
expect(await screen.findByTestId("scada-monitoring-console")).toHaveAttribute("data-device-count", "1");
expect(screen.queryByRole("table", { name: "SCADA 回放数据表" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "设定读数" })).not.toBeInTheDocument();
});
});
@@ -1,53 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
const REFRESH_INTERVAL_MS = 15_000;
const SCADA_ANOMALY_MARKER = "[SCADA_ANOMALY]";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { ScadaReplaySection } from "./scada-replay-section";
export function ScadaSystemSection({
active,
getAccessToken,
refreshGeneration,
peripheralClient,
canSimulate,
onAgentTrigger
refreshGeneration
}: {
active: boolean;
getAccessToken?: WorkbenchAccess;
refreshGeneration: number;
peripheralClient: PeripheralSampleClient;
canSimulate: boolean;
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null);
const requestSequenceRef = useRef(0);
const handledRefreshRef = useRef(refreshGeneration);
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null);
const [catalog, setCatalog] = useState<LatestScadaReadings | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);
const [simulationEditorOpen, setSimulationEditorOpen] = useState(false);
const [simulationPointId, setSimulationPointId] = useState("");
const [simulationValue, setSimulationValue] = useState("");
const [simulationObservedAt, setSimulationObservedAt] = useState(() => toLocalInput(new Date()));
const [simulationReason, setSimulationReason] = useState("");
const [simulationBusy, setSimulationBusy] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<PeripheralTestDataRecord[]>([]);
const [triggerRetry, setTriggerRetry] = useState<PeripheralTestDataRecord | null>(null);
const refresh = useCallback(async () => {
if (!active) return;
@@ -58,434 +31,57 @@ export function ScadaSystemSection({
setBusy(true);
try {
const next = await clientRef.current.getLatestReadings(controller.signal);
if (sequence !== requestSequenceRef.current) return;
setSnapshot(next);
while (next.items.length < next.total) {
const page = await clientRef.current.getLatestReadings(controller.signal, next.items.length);
if (!page.items.length) throw new Error("SCADA 设备目录分页不完整,请重新读取");
next.items.push(...page.items);
if (page.latest_observed_at && (!next.latest_observed_at
|| Date.parse(page.latest_observed_at) > Date.parse(next.latest_observed_at))) {
next.latest_observed_at = page.latest_observed_at;
}
}
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setCatalog(next);
setError(null);
setRefreshedAt(new Date());
} catch (nextError) {
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败");
setError(nextError instanceof Error ? nextError.message : "SCADA 设备目录读取失败");
} finally {
if (sequence === requestSequenceRef.current) setBusy(false);
}
}, [active]);
const refreshSimulations = useCallback(async () => {
if (!active || !canSimulate) return;
try {
const page = await peripheralClient.listTestData();
setSimulations(page.items.filter((item) => item.notes.startsWith(SCADA_ANOMALY_MARKER)));
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数读取失败");
}
}, [active, canSimulate, peripheralClient]);
useEffect(() => {
if (!active) return;
void refresh();
const intervalId = window.setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, REFRESH_INTERVAL_MS);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void refresh();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
requestRef.current?.abort();
};
return () => requestRef.current?.abort();
}, [active, refresh]);
useEffect(() => {
if (!active || handledRefreshRef.current === refreshGeneration) return;
handledRefreshRef.current = refreshGeneration;
void refresh();
void refreshSimulations();
}, [active, refresh, refreshGeneration, refreshSimulations]);
useEffect(() => {
void refreshSimulations();
}, [refreshSimulations]);
useEffect(() => {
if (!simulationPointId && snapshot?.items[0]) {
setSimulationPointId(snapshot.items[0].device_id);
}
}, [simulationPointId, snapshot]);
const visibleItems = useMemo(() => {
const query = filter.trim().toLocaleLowerCase("zh-CN");
if (!query) return snapshot?.items ?? [];
return (snapshot?.items ?? []).filter((item) =>
[item.device_id, item.device_type, item.measurement_unit, item.transmission_mode]
.some((value) => value.toLocaleLowerCase("zh-CN").includes(query))
);
}, [filter, snapshot]);
const selectedSimulationDevice = snapshot?.items.find(
(item) => item.device_id === simulationPointId
) ?? null;
const parsedSimulationValue = Number(simulationValue);
const canSaveSimulation = canSimulate && selectedSimulationDevice !== null
&& simulationValue.trim() !== "" && Number.isFinite(parsedSimulationValue)
&& Boolean(simulationObservedAt) && Boolean(simulationReason.trim()) && !simulationBusy;
const saveSimulation = async (triggerAgent: boolean) => {
if (!canSaveSimulation || !selectedSimulationDevice) return;
setSimulationBusy(true);
setSimulationError(null);
setTriggerRetry(null);
try {
const record = await peripheralClient.createTestData({
label: `SCADA 异常模拟 · ${selectedSimulationDevice.device_id}`,
point_id: selectedSimulationDevice.device_id,
observed_at: new Date(simulationObservedAt).toISOString(),
value: parsedSimulationValue,
unit: selectedSimulationDevice.measurement_unit || "未配置",
quality: "valid",
notes: `${SCADA_ANOMALY_MARKER} ${simulationReason.trim()}`
});
setSimulations((current) => [record, ...current.filter((item) => item.record_id !== record.record_id)]);
setSimulationEditorOpen(false);
setSimulationValue("");
setSimulationReason("");
setSimulationObservedAt(toLocalInput(new Date()));
if (triggerAgent) {
if (!onAgentTrigger) throw new Error("Agent 异常处理入口未配置");
try {
await onAgentTrigger(record);
} catch (triggerError) {
setTriggerRetry(record);
setSimulationError(
`模拟读数已保存,但 Agent 任务未建立:${triggerError instanceof Error ? triggerError.message : "触发失败"}`
);
}
}
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数保存失败");
} finally {
setSimulationBusy(false);
}
};
const retryAgentTrigger = async () => {
if (!triggerRetry || !onAgentTrigger) return;
setSimulationBusy(true);
setSimulationError(null);
try {
await onAgentTrigger(triggerRetry);
setTriggerRetry(null);
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "Agent 任务触发失败");
} finally {
setSimulationBusy(false);
}
};
}, [active, refresh, refreshGeneration]);
return (
<div className="space-y-3" lang="zh-CN">
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
</p>
{error ? (
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
{error}。已加载的数据会继续保留。
<WorkbenchNotice role="alert" tone="danger" title="SCADA 演示数据未更新">
{error}。已加载的演示区间仍可继续使用。
</WorkbenchNotice>
) : null}
{simulationError ? (
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成">
<p>{simulationError}</p>
{triggerRetry ? (
<Button
className="mt-2"
size="sm"
variant="outline"
disabled={simulationBusy}
onClick={() => void retryAgentTrigger()}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
重试触发 Agent
</Button>
) : null}
{!catalog && busy ? (
<WorkbenchNotice role="status" tone="neutral" title="正在准备 SCADA 演示">
正在读取项目测点配置和历史数据范围。
</WorkbenchNotice>
) : null}
<section
aria-label="SCADA 最新数据概览"
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border px-1 py-3"
>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p>
<p className="mt-1 text-xl font-semibold leading-7 text-foreground tabular-nums">
{snapshot ? snapshot.observed_count : "—"}
<span className="ml-1 text-xs font-medium text-muted-foreground">
/ {snapshot ? snapshot.total : "—"} 个设备
</span>
</p>
</div>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">最近记录时间</p>
<p className="mt-1 text-sm font-semibold leading-7 text-foreground tabular-nums">
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
</p>
</div>
</section>
<WorkbenchSection
title="最新测点值"
description={refreshedAt
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
icon={<Activity size={15} />}
tone="teal"
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
bodyClassName="p-0"
>
<div className="border-b border-border p-3">
<div className="relative">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="筛选 SCADA 设备"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="筛选设备编号、类型或单位"
className="h-10 bg-card pl-9 text-xs md:text-xs"
/>
</div>
</div>
{!snapshot && busy ? (
<p role="status" className="px-4 py-8 text-center text-xs text-muted-foreground">
正在读取 SCADA 最新数据
</p>
) : visibleItems.length === 0 ? (
<WorkbenchEmptyState
icon={<Database size={16} />}
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
className="m-3"
/>
) : (
<div className="max-h-80 overflow-auto overscroll-contain">
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
<thead className="sticky top-0 z-10 bg-muted text-[11px] text-muted-foreground">
<tr className="border-b border-border">
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item) => (
<tr key={item.device_id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-3 py-2.5 text-left font-medium text-foreground">
<span className="block font-mono text-[12px]">{item.device_id}</span>
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
</span>
</th>
<td className="px-3 py-2.5 text-right font-mono text-foreground tabular-nums">
{formatValue(item.monitored_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-muted-foreground tabular-nums">
{formatValue(item.cleaned_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{item.observed_at ? (
<time dateTime={item.observed_at} className="text-foreground">
{formatObservedTime(item.observed_at)}
</time>
) : (
<StatusBadge tone="neutral">无记录</StatusBadge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{snapshot && snapshot.total > snapshot.items.length ? (
<p className="border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
</p>
) : null}
</WorkbenchSection>
<WorkbenchSection
title="异常读数演练"
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
icon={<Sparkles size={15} />}
tone="warning"
actions={canSimulate ? (
<Button
size="sm"
variant="outline"
aria-expanded={simulationEditorOpen}
onClick={() => setSimulationEditorOpen((current) => !current)}
disabled={simulationBusy || !snapshot?.items.length}
>
{simulationEditorOpen ? <X size={13} /> : <Plus size={13} />}
{simulationEditorOpen ? "收起" : "设定读数"}
</Button>
) : null}
bodyClassName="space-y-3"
>
{!canSimulate ? (
<WorkbenchNotice tone="warning" title="合成读数存储不可用">
需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。
</WorkbenchNotice>
) : null}
{simulationEditorOpen && canSimulate ? (
<div className="border-b border-border pb-4">
<div className="grid gap-3 sm:grid-cols-2">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="SCADA 设备"
value={simulationPointId}
onValueChange={setSimulationPointId}
disabled={simulationBusy}
options={(snapshot?.items ?? []).map((item) => ({
value: item.device_id,
label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}`
}))}
/>
<DateTimeField
contentClassName="workbench-shadcn-surface"
label="模拟发生时间"
value={simulationObservedAt}
onChange={setSimulationObservedAt}
disabled={simulationBusy}
/>
</div>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
模拟读数{selectedSimulationDevice?.measurement_unit
? `(${selectedSimulationDevice.measurement_unit})`
: ""}
<Input
className="mt-1.5 h-10 bg-card text-sm tabular-nums"
inputMode="decimal"
value={simulationValue}
onChange={(event) => setSimulationValue(event.target.value)}
disabled={simulationBusy}
placeholder="输入需要演练的异常值"
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
/>
</label>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
异常说明
<Textarea
className="mt-1.5 min-h-20 bg-card"
value={simulationReason}
onChange={(event) => setSimulationReason(event.target.value)}
disabled={simulationBusy}
placeholder="例如:压力突降,偏离近一小时稳定区间"
/>
</label>
<p className="mt-2 text-[11px] leading-5 text-muted-foreground">
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(true)}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
保存并触发 Agent
</Button>
<Button
size="sm"
variant="outline"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(false)}
>
保存模拟读数
</Button>
</div>
</div>
) : null}
{simulations.length ? (
<ol aria-label="SCADA 模拟异常列表" className="space-y-2">
{simulations.slice(0, 5).map((item) => (
<li
key={item.record_id}
className="flex min-w-0 items-start gap-3 rounded-xl bg-muted px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
>
<StatusBadge tone="warning">模拟异常</StatusBadge>
<div className="min-w-0 flex-1">
<p className="font-mono text-xs font-semibold text-foreground">{item.point_id}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
</p>
<p className="mt-1 break-words text-[11px] leading-4 text-foreground">
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
</p>
</div>
</li>
))}
</ol>
) : canSimulate && !simulationEditorOpen ? (
<p className="text-[11px] leading-5 text-muted-foreground">尚未设定 SCADA 模拟异常。</p>
) : null}
</WorkbenchSection>
{catalog ? (
<ScadaReplaySection
active={active}
getAccessToken={getAccessToken}
catalog={catalog}
/>
) : null}
</div>
);
}
function formatValue(value: number | null, unit: string) {
if (value === null) return "—";
const formatted = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 4 }).format(value);
return `${formatted}${unit ? ` ${unit}` : ""}`;
}
function formatObservedTime(value: string | null) {
if (!value) return "暂无记录";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "时间无效";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(date);
}
function formatClock(value: Date) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(value);
}
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function deviceTypeLabel(value: string) {
return ({
pressure: "压力",
pipe_flow: "流量",
quality: "水质",
demand: "需水量",
reservoir_liquid_level: "水库液位",
tank_liquid_level: "水箱液位",
fixed_pump: "定速泵",
variable_pump: "变频泵"
} as Record<string, string>)[value] ?? value;
}
@@ -0,0 +1,168 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "motion/react";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
const MINUTE = 60_000;
const TIME_FORMAT = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
const SECOND_FORMAT = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
const DATE_FORMAT = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" });
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;
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange }: {
start: number; end: number; value: number; step: number; following: boolean; browsing?: boolean; onChange: (time: number) => void;
}) {
const surface = useRef<HTMLDivElement>(null);
const dragging = useRef<{ x: number; time: number; pointerId: number } | null>(null);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const [width, setWidth] = useState(500);
const reduced = useReducedMotion();
const selectedValue = snapTimelineTime(value, start, end, step);
const position = useMotionValue(selectedValue);
const animation = useRef<{ stop: () => void } | 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;
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
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();
}, [following, position, reduced, selectedValue]);
useEffect(() => () => {
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
dragging.current = null;
animation.current?.stop();
}, []);
useEffect(() => {
if (!surface.current) return;
const resize = () => setWidth(Math.max(200, surface.current?.clientWidth || 500));
resize();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(200, entry.contentRect.width)));
observer.observe(surface.current);
return () => observer.disconnect();
}, []);
const span = Math.max(1, Math.min(end - start, 6 * step));
const timeFormat = step % MINUTE ? SECOND_FORMAT : TIME_FORMAT;
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 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)
])]
.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]);
const clamp = (time: number) => Math.max(start, Math.min(end, time));
const snap = (time: number) => snapTimelineTime(time, start, end, step);
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);
};
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>
<div ref={surface} role="slider" tabIndex={0} aria-label="SCADA 调频时间轴"
data-dragging={isDragging}
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"
onPointerDown={(event) => {
if (event.button !== 0) return;
event.stopPropagation();
if (dragging.current) return;
if (event.pointerType === "mouse") event.preventDefault();
animation.current?.stop();
dragging.current = { x: event.clientX, time: position.get(), pointerId: event.pointerId };
setPreviewTime(snap(position.get()));
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus({ preventScroll: true });
}}
onPointerMove={(event) => {
if (dragging.current?.pointerId === event.pointerId) {
position.set(clamp(dragging.current.time - (event.clientX - dragging.current.x) / width * span));
if (previewFrame.current === null) previewFrame.current = requestAnimationFrame(() => {
previewFrame.current = null;
setPreviewTime(snap(position.get()));
});
if (publishTimer.current === null) publishTimer.current = setTimeout(() => {
publishTimer.current = null;
if (dragging.current) onChangeRef.current(snap(position.get()));
}, DRAG_PUBLISH_MS);
}
}}
onPointerUp={(event) => finishDrag(event.pointerId)} onPointerCancel={(event) => finishDrag(event.pointerId)}
onLostPointerCapture={(event) => finishDrag(event.pointerId)}
onKeyDown={(event) => {
const key = event.key;
const next = key === "Home" ? start : key === "End" ? end
: key === "ArrowLeft" || key === "ArrowDown" ? moveTimelineTime(displayTime, start, end, step, -1)
: key === "ArrowRight" || key === "ArrowUp" ? moveTimelineTime(displayTime, start, end, step, 1)
: key === "PageUp" ? moveTimelineTime(displayTime, start, end, step, major / step)
: key === "PageDown" ? moveTimelineTime(displayTime, start, end, step, -major / step) : null;
if (next !== null) {
event.preventDefault(); event.stopPropagation();
animation.current?.stop();
position.set(next);
onChange(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" />
</div>
</div>
);
});
const ScadaTick = memo(function ScadaTick({ time, endpoint, timeFormat, width, span, major, minor, lensRadius, position }: {
time: number; width: number; span: number; major: number; minor: number; lensRadius: number; position: MotionValue<number>;
endpoint: boolean; timeFormat: Intl.DateTimeFormat;
}) {
const isMajor = endpoint || Math.abs(time % major) < 1;
const isMedium = !isMajor && Math.abs(time % minor) < 1;
const offset = useTransform(position, (current) => (time - current) / span * width);
const focus = useTransform(offset, (x) => Math.cos(Math.min(1, Math.abs(x) / lensRadius) * Math.PI / 2) ** 2);
const scaleY = useTransform(focus, (amount) => 1 + amount * 0.25);
const scaleX = useTransform(focus, (amount) => 1 + amount * 0.3);
return <motion.div className="absolute left-1/2 top-5 flex w-20 -translate-x-1/2 flex-col items-center" style={{ x: offset }}>
<motion.span style={{ scaleY, scaleX }}
className={`origin-bottom ${isMajor ? "h-10 w-px bg-foreground/55" : isMedium ? "mt-4 h-6 w-px bg-foreground/35" : "mt-7 h-3 w-px bg-foreground/20"}`} />
{isMajor ? <ScadaTickLabel time={time} focus={focus} timeFormat={timeFormat} /> : null}
</motion.div>;
});
function ScadaTickLabel({ time, focus, timeFormat }: { time: number; focus: MotionValue<number>; timeFormat: Intl.DateTimeFormat }) {
const scale = useTransform(focus, (amount) => 1 + amount * 0.55);
const opacity = useTransform(focus, (amount) => 0.65 + amount * 0.35);
return <motion.span style={{ scale, opacity }} className="mt-2 origin-top text-[11px] text-foreground tabular-nums">{timeFormat.format(time)}</motion.span>;
}
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { WorkbenchAccess } from "../api/request-context";
import { ScadaReplayClient, type ScadaReplay, type ScadaReplayAction, type ScadaReplayCapabilities } from "../api/scada-replay-client";
import { reuseReplayProjection } from "./scada-replay-readings";
export function useScadaReplay(active: boolean, access?: WorkbenchAccess) {
const client = useRef(new ScadaReplayClient(access)).current;
const [capabilities, setCapabilities] = useState<ScadaReplayCapabilities | null>(null);
const [replay, setReplay] = useState<ScadaReplay | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const mutation = useRef(false);
const replayRef = useRef(replay);
replayRef.current = replay;
const refreshSequence = useRef(0);
const refresh = useCallback(async (signal?: AbortSignal) => {
const generation = ++refreshSequence.current;
try {
const cap = await client.capabilities(signal);
if (signal?.aborted || generation !== refreshSequence.current || mutation.current) return;
setCapabilities(cap);
setReplay((previous) => reuseReplayProjection(previous, cap.current));
setError(null);
} catch (nextError) {
if (!signal?.aborted && generation === refreshSequence.current) {
setError(nextError instanceof Error ? nextError.message : "SCADA 回放读取失败");
}
}
}, [client]);
useEffect(() => {
if (!active) return;
const controller = new AbortController();
let timer: number | undefined;
let disposed = false;
const poll = async () => {
if (document.visibilityState === "visible" && !mutation.current) await refresh(controller.signal);
if (!disposed) timer = window.setTimeout(() => void poll(), replayRef.current?.step_pending ? 1000 : 5000);
};
void poll();
const visible = () => { if (document.visibilityState === "visible") void refresh(controller.signal); };
document.addEventListener("visibilitychange", visible);
return () => {
disposed = true;
window.clearTimeout(timer);
controller.abort();
document.removeEventListener("visibilitychange", visible);
};
}, [active, refresh]);
const change = async (operation: () => Promise<ScadaReplay>) => {
if (mutation.current) return;
mutation.current = true;
++refreshSequence.current;
setBusy(true); setError(null);
try {
const next = await operation();
setReplay((previous) => reuseReplayProjection(previous, next));
}
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();
return change(() => client.command(replay, action));
}
};
}