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
+3 -1
View File
@@ -32,7 +32,9 @@ Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白
助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。 助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。
Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试与地图调试两个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 先展示最新值,再提供异常演练。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。 Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试与地图调试两个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 只保留演示区间、调频时间轴、主动监测操作和本周期结论,测点目录与原始读数表不在演示面板重复展示。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。
SCADA 演示时间轴按用户指定采用 FM 调频盘样式:在选定时间区间内,玻璃刻度窗中的时间刻度带横向移动,红色细指示线固定居中。半透明、背景柔化、边缘高光和轻微内阴影仅用于这块刻度窗,数字、刻度和指示线保持清晰,其他外围区域继续采用实体表面。时间定位红色使用独立语义变量,不表示危险告警;背景模糊不可用时回退到实体底色。时间轴按 5 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,选定值及起止时间仍量化到 5 分钟。时间轴只用于回看且不能触发数据处理,方向键也按 5 分钟移动,监测按钮另按项目周期推进后台处理。减少动态效果时关闭连续平滑位移。详细区间与回放语义见 [SCADA 外围系统时段回放与异常检测设计](../../next-tjwater-server/docs/scada-peripheral-replay-design.md)。
Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。 Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。
+4 -3
View File
@@ -44,7 +44,8 @@ const latestScadaReadingSchema = z.object({
transmission_frequency: z.string(), transmission_frequency: z.string(),
observed_at: z.string().datetime({ offset: true }).nullable(), observed_at: z.string().datetime({ offset: true }).nullable(),
monitored_value: z.number().finite().nullable(), monitored_value: z.number().finite().nullable(),
cleaned_value: z.number().finite().nullable() cleaned_value: z.number().finite().nullable(),
quality: z.enum(["valid", "corrected", "missing", "invalid", "unvalidated"]).optional()
}); });
const latestScadaReadingsSchema = z.object({ const latestScadaReadingsSchema = z.object({
@@ -76,14 +77,14 @@ export class ScadaSystemClient {
constructor(private readonly getAccessToken?: WorkbenchAccess) {} constructor(private readonly getAccessToken?: WorkbenchAccess) {}
async getLatestReadings(signal?: AbortSignal): Promise<LatestScadaReadings> { async getLatestReadings(signal?: AbortSignal, offset = 0): Promise<LatestScadaReadings> {
const [projectId, accessToken] = await Promise.all([ const [projectId, accessToken] = await Promise.all([
this.resolveProjectId(), this.resolveProjectId(),
getWorkbenchAccessToken(this.getAccessToken) getWorkbenchAccessToken(this.getAccessToken)
]); ]);
return latestScadaReadingsSchema.parse( return latestScadaReadingsSchema.parse(
await requestJson( await requestJson(
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0", `/api/v1/timeseries/scada-readings/latest?limit=500&offset=${offset}`,
accessToken, accessToken,
projectId, projectId,
signal signal
@@ -0,0 +1,102 @@
import { z } from "zod";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
const ruleSchema = z.object({
rule_id: z.string(), device_ids: z.array(z.string()), kind: z.enum(["relative_change", "bounds"]),
direction: z.enum(["both", "increase", "decrease"]).optional(), threshold: z.number().nullable().optional(),
lower: z.number().nullable().optional(), upper: z.number().nullable().optional(), unit: z.string(), metric_name: z.string().nullable().optional(),
minimum_samples: z.number().optional(), window_samples: z.number().optional(),
consecutive_samples: z.number().optional(), recovery_samples: z.number().optional(), minimum_devices: z.number().optional()
});
const readingSchema = z.object({
time: z.string().datetime({ offset: true }), device_id: z.string(),
monitored_value: z.number().finite().nullable(), cleaned_value: z.number().finite().nullable(),
quality: z.enum(["valid", "corrected", "missing", "invalid", "unvalidated"]), reason: z.string()
});
const eventSchema = z.object({
event_id: z.string(), rule_id: z.string(), device_ids: z.array(z.string()),
occurred_at: z.string(), last_observed_at: z.string(), status: z.enum(["active", "resolved"]),
detail: z.string(), task_id: z.string().nullable(), category: z.string().optional(),
change: z.enum(["raised", "resolved"]).optional(), resolved_at: z.string().optional()
});
const monitoringSchema = z.object({
monitoring_id: z.string().uuid(),
period_start: z.string(), period_end: z.string(), knowledge_cutoff: z.string(),
batch_sequence: z.number().int(), rule_version: z.string(),
status: z.enum(["normal", "attention", "insufficient_data", "suspected_burst"]),
detail: z.string(), task_id: z.string().uuid().nullable(),
pressure: z.record(z.string(), z.unknown()),
outlet_flow: z.record(z.string(), z.unknown()),
burst_detection: z.record(z.string(), z.unknown())
}).passthrough();
export const scadaReplaySchema = z.object({
replay_id: z.string().uuid(), project_id: z.string().uuid(), revision: z.number().int(),
status: z.enum(["ready", "paused", "completed", "stopped", "failed"]),
start_time: z.string(), end_time: z.string(), virtual_now: z.string(),
monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null),
step_pending: z.boolean().default(false),
monitoring_count: z.number().int().default(0),
last_monitoring: monitoringSchema.nullable().default(null),
delivered_until: z.string().nullable(), processed_until: z.string().nullable(),
processed_count: z.number(), anomaly_count: z.number(),
devices: z.array(z.object({ device_id: z.string(), measurement_unit: z.string(), device_type: z.string() })),
rules: z.array(ruleSchema), latest: z.array(readingSchema), active_events: z.array(eventSchema),
error: z.string().nullable(), created_at: z.string(), updated_at: z.string()
});
const capabilitiesSchema = z.object({
available: z.boolean(), can_write: z.boolean(), reason: z.string().nullable(), current: scadaReplaySchema.nullable(),
monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null),
burst_monitoring_ready: z.boolean().default(false),
burst_monitoring_reason: z.string().nullable().default(null)
});
export type ScadaReplay = z.infer<typeof scadaReplaySchema>;
export type ScadaReplayRule = z.infer<typeof ruleSchema>;
export type ScadaReplayEvent = z.infer<typeof eventSchema>;
export type ScadaMonitoringResult = z.infer<typeof monitoringSchema>;
export type ScadaReplayCapabilities = z.infer<typeof capabilitiesSchema>;
export type ScadaReplayAction = "step" | "stop" | "retry";
export class ScadaReplayClient {
private projectId: Promise<string> | null = null;
constructor(private readonly access?: WorkbenchAccess) {}
async capabilities(signal?: AbortSignal) {
return capabilitiesSchema.parse(await this.request("/capabilities", undefined, signal));
}
async get(id: string, signal?: AbortSignal) {
return scadaReplaySchema.parse(await this.request(`/${encodeURIComponent(id)}`, undefined, signal));
}
async events(id: string, signal?: AbortSignal) {
return z.object({ items: z.array(eventSchema), next_offset: z.number().nullable() })
.parse(await this.request(`/${encodeURIComponent(id)}/events?limit=100&offset=0`, undefined, signal)).items;
}
async observations(id: string, through: string, signal?: AbortSignal) {
return z.object({ items: z.array(readingSchema), through: z.string() }).parse(
await this.request(`/${encodeURIComponent(id)}/observations?${new URLSearchParams({ through })}`, undefined, signal)
).items;
}
async create(input: { start_time: string; end_time: string; device_ids: string[]; rules: ScadaReplayRule[] }) {
return scadaReplaySchema.parse(await this.request("", { request_id: crypto.randomUUID(), ...input }));
}
async command(replay: ScadaReplay, action: ScadaReplayAction) {
return scadaReplaySchema.parse(await this.request(`/${replay.replay_id}/commands`, {
request_id: crypto.randomUUID(), expected_revision: replay.revision, action
}));
}
private async request(path: string, body?: unknown, signal?: AbortSignal) {
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
.catch((error) => { this.projectId = null; throw error; });
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
const headers = createWorkbenchHeaders(token, projectId);
if (body !== undefined) headers.set("Content-Type", "application/json");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
method: body === undefined ? "GET" : "POST", headers, signal,
...(body === undefined ? {} : { body: JSON.stringify(body) })
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
return payload;
}
}
@@ -38,11 +38,11 @@ type OperationalRuntimeConditionPayload = RuntimeConditionPayload & {
const runtimeAlertSchema = z.object({ const runtimeAlertSchema = z.object({
id: z.string().trim().min(1), id: z.string().trim().min(1),
condition_id: z.string().trim().min(1).nullable(), condition_id: z.string().trim().min(1).nullable(),
source: z.object({ source: z.union([z.object({
task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]), task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]),
task_id: z.string().trim().min(1), task_id: z.string().trim().min(1),
run_id: z.string().trim().min(1) run_id: z.string().trim().min(1)
}), }), z.object({ kind: z.literal("scada_replay"), replay_id: z.string(), rule_id: z.string(), batch_sequence: z.number().int() })]),
title: z.string().trim().min(1), title: z.string().trim().min(1),
description: z.string().trim().min(1), description: z.string().trim().min(1),
occurred_at: z.string().datetime({ offset: true }), occurred_at: z.string().datetime({ offset: true }),
@@ -199,7 +199,12 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
id: alert.id, id: alert.id,
title: alert.title, title: alert.title,
description: alert.description, description: alert.description,
source: { source: "kind" in alert.source ? {
kind: "scada_replay" as const,
replayId: alert.source.replay_id,
ruleId: alert.source.rule_id,
batchSequence: alert.source.batch_sequence
} : {
taskType: alert.source.task_type, taskType: alert.source.task_type,
taskId: alert.source.task_id, taskId: alert.source.task_id,
runId: alert.source.run_id runId: alert.source.run_id
+1 -41
View File
@@ -38,8 +38,7 @@ import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-m
import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel"; import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel"; import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralTestDataRecord, PeripheralWorkOrder } from "./peripheral-sample/client"; import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { buildScadaAnomalyInvestigation } from "./peripheral-sample/scada-anomaly-agent-trigger";
import { MapDevPanel } from "./components/map-dev-panel"; import { MapDevPanel } from "./components/map-dev-panel";
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel"; import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
@@ -405,44 +404,6 @@ function MapWorkbenchContent({
} }
} }
async function handleScadaAnomalyAgentTrigger(record: PeripheralTestDataRecord) {
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "loading",
title: "SCADA 模拟异常已接收",
message: "正在建立结构化异常调查任务。",
duration: Infinity
});
try {
const task = await operationalTasks.createInvestigation(
buildScadaAnomalyInvestigation(record)
);
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
const conditionId = operationalTaskConditionId(task.task_id);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "success",
title: "已建立 SCADA 异常调查",
message: `关联模拟读数 ${record.record_id.slice(0, 8)},Agent 将按任务上下文核查。`
});
} catch (error) {
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "error",
title: "异常调查任务创建失败",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
}
}
function openConditionWorkspaceForViewport() { function openConditionWorkspaceForViewport() {
openConditionFeedForViewport(); openConditionFeedForViewport();
} }
@@ -1376,7 +1337,6 @@ function MapWorkbenchContent({
onClose={() => setPeripheralSamplePanelOpen(false)} onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken} getAccessToken={getAccessToken}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger} onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
onScadaAnomalyTrigger={handleScadaAnomalyAgentTrigger}
/> />
<WorkbenchAgentPanels <WorkbenchAgentPanels
@@ -12,7 +12,6 @@ import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { import {
PeripheralSampleClient, PeripheralSampleClient,
type PeripheralContext, type PeripheralContext,
type PeripheralTestDataRecord,
type PeripheralWorkOrder type PeripheralWorkOrder
} from "./client"; } from "./client";
@@ -22,14 +21,12 @@ export function PeripheralSamplePanel({
open, open,
onClose, onClose,
getAccessToken, getAccessToken,
onAgentTrigger, onAgentTrigger
onScadaAnomalyTrigger
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
getAccessToken?: WorkbenchAccess; getAccessToken?: WorkbenchAccess;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>; onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) { }) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken)); const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current; const client = clientRef.current;
@@ -72,7 +69,7 @@ export function PeripheralSamplePanel({
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2> <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> </div>
<Button <Button
size="icon" size="icon"
@@ -171,11 +168,6 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"} active={activeSystem === "scada"}
getAccessToken={getAccessToken} getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration} refreshGeneration={scadaRefreshGeneration}
peripheralClient={client}
canSimulate={Boolean(
context?.can_write && context.managed_resources.test_data === "available"
)}
onAgentTrigger={onScadaAnomalyTrigger}
/> />
</TabsContent> </TabsContent>
</Tabs> </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 { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
import { ScadaSystemSection } from "./scada-system-section"; 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", () => { describe("SCADA peripheral system", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); 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 const fetchMock = vi
.fn() .fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ .mockResolvedValueOnce(new Response(JSON.stringify({
@@ -38,55 +44,10 @@ describe("SCADA peripheral system", () => {
}), { status: 200 })); }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const record: PeripheralTestDataRecord = { render(<ScadaSystemSection active refreshGeneration={0} />);
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( expect(await screen.findByTestId("scada-monitoring-console")).toHaveAttribute("data-device-count", "1");
<ScadaSystemSection expect(screen.queryByRole("table", { name: "SCADA 回放数据表" })).not.toBeInTheDocument();
active expect(screen.queryByRole("button", { name: "设定读数" })).not.toBeInTheDocument();
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);
}); });
}); });
@@ -1,53 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client"; import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context"; import type { WorkbenchAccess } from "../api/request-context";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui"; import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { Button } from "@/shared/ui/button"; import { ScadaReplaySection } from "./scada-replay-section";
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]";
export function ScadaSystemSection({ export function ScadaSystemSection({
active, active,
getAccessToken, getAccessToken,
refreshGeneration, refreshGeneration
peripheralClient,
canSimulate,
onAgentTrigger
}: { }: {
active: boolean; active: boolean;
getAccessToken?: WorkbenchAccess; getAccessToken?: WorkbenchAccess;
refreshGeneration: number; refreshGeneration: number;
peripheralClient: PeripheralSampleClient;
canSimulate: boolean;
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) { }) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken)); const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null); const requestRef = useRef<AbortController | null>(null);
const requestSequenceRef = useRef(0); const requestSequenceRef = useRef(0);
const handledRefreshRef = useRef(refreshGeneration); const handledRefreshRef = useRef(refreshGeneration);
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null); const [catalog, setCatalog] = useState<LatestScadaReadings | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); 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 () => { const refresh = useCallback(async () => {
if (!active) return; if (!active) return;
@@ -58,434 +31,57 @@ export function ScadaSystemSection({
setBusy(true); setBusy(true);
try { try {
const next = await clientRef.current.getLatestReadings(controller.signal); const next = await clientRef.current.getLatestReadings(controller.signal);
if (sequence !== requestSequenceRef.current) return; while (next.items.length < next.total) {
setSnapshot(next); 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); setError(null);
setRefreshedAt(new Date());
} catch (nextError) { } catch (nextError) {
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return; if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败"); setError(nextError instanceof Error ? nextError.message : "SCADA 设备目录读取失败");
} finally { } finally {
if (sequence === requestSequenceRef.current) setBusy(false); if (sequence === requestSequenceRef.current) setBusy(false);
} }
}, [active]); }, [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(() => { useEffect(() => {
if (!active) return; if (!active) return;
void refresh(); void refresh();
const intervalId = window.setInterval(() => { return () => requestRef.current?.abort();
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();
};
}, [active, refresh]); }, [active, refresh]);
useEffect(() => { useEffect(() => {
if (!active || handledRefreshRef.current === refreshGeneration) return; if (!active || handledRefreshRef.current === refreshGeneration) return;
handledRefreshRef.current = refreshGeneration; handledRefreshRef.current = refreshGeneration;
void refresh(); void refresh();
void refreshSimulations(); }, [active, refresh, refreshGeneration]);
}, [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);
}
};
return ( return (
<div className="space-y-3" lang="zh-CN"> <div className="space-y-3" lang="zh-CN">
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
</p>
{error ? ( {error ? (
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新"> <WorkbenchNotice role="alert" tone="danger" title="SCADA 演示数据未更新">
{error}。已加载的数据会继续保留。 {error}。已加载的演示区间仍可继续使用。
</WorkbenchNotice> </WorkbenchNotice>
) : null} ) : null}
{simulationError ? ( {!catalog && busy ? (
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成"> <WorkbenchNotice role="status" tone="neutral" title="正在准备 SCADA 演示">
<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}
</WorkbenchNotice> </WorkbenchNotice>
) : null} ) : null}
{catalog ? (
<section <ScadaReplaySection
aria-label="SCADA 最新数据概览" active={active}
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border px-1 py-3" getAccessToken={getAccessToken}
> catalog={catalog}
<div className="px-3"> />
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p> ) : null}
<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>
</div> </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));
}
};
}
+1 -1
View File
@@ -31,7 +31,7 @@ export type WorkbenchAlert = {
taskType: "scheduled_task" | "analysis_task" | "other_task"; taskType: "scheduled_task" | "analysis_task" | "other_task";
taskId: string; taskId: string;
runId: string; runId: string;
}; } | { kind: "scada_replay"; replayId: string; ruleId: string; batchSequence: number };
severity?: "warning" | "critical"; severity?: "warning" | "critical";
occurredAt?: string; occurredAt?: string;
time: string; time: string;
+10
View File
@@ -33,6 +33,16 @@ describe("DateTimeField", () => {
expect(onChange).toHaveBeenCalledWith("2024-02-29T09:30"); expect(onChange).toHaveBeenCalledWith("2024-02-29T09:30");
}); });
it("limits minute choices to the configured step", async () => {
const user = userEvent.setup();
render(<DateTimeField label="演示开始时间" value="2024-02-28T09:25" onChange={vi.fn()} minuteStep={5} />);
await user.click(screen.getByRole("button", { name: "演示开始时间" }));
await user.click(screen.getByRole("combobox", { name: "分钟" }));
expect(screen.getByRole("option", { name: "25" })).toBeVisible();
expect(screen.queryByRole("option", { name: "26" })).not.toBeInTheDocument();
});
it.each(["2025-02-29T12:00", "2024-04-31T12:00", "2024-02-29T12:00:30"])( it.each(["2025-02-29T12:00", "2024-04-31T12:00", "2024-02-29T12:00:30"])(
"rejects invalid or unexpected source value %s", "rejects invalid or unexpected source value %s",
async (value) => { async (value) => {
+6 -3
View File
@@ -14,6 +14,7 @@ export interface DateTimeFieldProps {
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
contentClassName?: string; contentClassName?: string;
minuteStep?: number;
} }
interface DateTimeParts { interface DateTimeParts {
@@ -122,7 +123,9 @@ function moveDay(parts: DateTimeParts, amount: number): DateTimeParts {
return { ...parts, year, month: date.getMonth() + 1, day: date.getDate() }; return { ...parts, year, month: date.getMonth() + 1, day: date.getDate() };
} }
export function DateTimeField({ label, value, onChange, disabled = false, contentClassName }: DateTimeFieldProps) { export function DateTimeField({ label, value, onChange, disabled = false, contentClassName, minuteStep = 1 }: DateTimeFieldProps) {
const safeMinuteStep = Number.isInteger(minuteStep) && minuteStep > 0 && 60 % minuteStep === 0 ? minuteStep : 1;
const minuteOptions = MINUTE_OPTIONS.filter((_, minute) => minute % safeMinuteStep === 0);
const id = useId(); const id = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<DateTimeParts>( const [draft, setDraft] = useState<DateTimeParts>(
@@ -133,7 +136,7 @@ export function DateTimeField({ label, value, onChange, disabled = false, conten
const selectedDayRef = useRef<HTMLButtonElement>(null); const selectedDayRef = useRef<HTMLButtonElement>(null);
const focusDayAfterNavigation = useRef(false); const focusDayAfterNavigation = useRef(false);
const validYear = /^\d{1,4}$/.test(yearText) && Number(yearText) >= 1; const validYear = /^\d{1,4}$/.test(yearText) && Number(yearText) >= 1;
const canConfirm = !disabled && !sourceInvalid && validYear && isValidParts(draft); const canConfirm = !disabled && !sourceInvalid && validYear && isValidParts(draft) && draft.minute % safeMinuteStep === 0;
const resetDraft = (nextValue: string) => { const resetDraft = (nextValue: string) => {
const parsed = parseDateTimeValue(nextValue); const parsed = parseDateTimeValue(nextValue);
@@ -364,7 +367,7 @@ export function DateTimeField({ label, value, onChange, disabled = false, conten
contentClassName={contentClassName} contentClassName={contentClassName}
label="分钟" label="分钟"
value={pad(draft.minute)} value={pad(draft.minute)}
options={MINUTE_OPTIONS} options={minuteOptions}
onValueChange={(minute) => { onValueChange={(minute) => {
setDraft({ ...draft, minute: Number(minute) }); setDraft({ ...draft, minute: Number(minute) });
setSourceInvalid(false); setSourceInvalid(false);
+74
View File
@@ -2530,3 +2530,77 @@ body:has(:is(.agent-history-dialog, .agent-topic-dialog)[data-state="open"]) [da
.workbench-shadcn-surface .font-mono { .workbench-shadcn-surface .font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Noto Sans SC Variable", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Noto Sans SC Variable", monospace;
} }
/* SCADA tuning dial: the explicitly scoped glass surface in the peripheral panel. */
.scada-fm-window {
--scada-timeline-needle: #cf453f;
isolation: isolate;
background: linear-gradient(180deg,
color-mix(in srgb, var(--surface-reading) 50%, transparent),
color-mix(in srgb, var(--surface-control) 42%, transparent) 52%,
color-mix(in srgb, var(--surface-reading) 76%, transparent));
border: 1px solid color-mix(in srgb, white 76%, var(--surface-border));
backdrop-filter: blur(12px) saturate(1.05);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 95%), inset 0 -1px 0 rgb(255 255 255 / 70%), inset 0 8px 18px rgb(68 86 112 / 4%), 0 2px 7px rgb(45 60 82 / 5%);
cursor: grab;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.scada-fm-window:hover {
border-color: color-mix(in srgb, white 50%, var(--surface-border));
}
.scada-fm-window:focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 3px;
}
.scada-fm-window[data-dragging="true"] {
cursor: grabbing;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 95%), inset 0 2px 12px rgb(68 86 112 / 9%), 0 1px 4px rgb(45 60 82 / 4%);
}
.scada-fm-window::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(180deg, rgb(255 255 255 / 55%), rgb(255 255 255 / 10%) 35%, transparent 36%, transparent 75%, rgb(255 255 255 / 30%));
}
.scada-fm-window::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(90deg,
color-mix(in srgb, var(--ink) 13%, transparent),
color-mix(in srgb, var(--ink) 5%, transparent) 10%,
transparent 24%,
transparent 76%,
color-mix(in srgb, var(--ink) 5%, transparent) 90%,
color-mix(in srgb, var(--ink) 13%, transparent));
}
.scada-fm-scale {
mask-image: linear-gradient(90deg, transparent, rgb(0 0 0 / 45%) 8%, black 22%, black 78%, rgb(0 0 0 / 45%) 92%, transparent);
}
.scada-fm-needle {
z-index: 3;
background: var(--scada-timeline-needle);
box-shadow: 0 0 0 1px rgb(255 255 255 / 45%), 0 0 7px color-mix(in srgb, var(--scada-timeline-needle) 18%, transparent);
}
.scada-fm-needle::before {
content: "";
position: absolute;
top: -5px;
left: -3px;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid var(--scada-timeline-needle);
}
@supports not (backdrop-filter: blur(1px)) {
.scada-fm-window { background: var(--surface-reading, #f7f9fc); }
}
@media (prefers-reduced-motion: reduce) {
.scada-fm-window { transition: none; }
}
+124 -55
View File
@@ -162,9 +162,8 @@ test.describe("外围系统", () => {
expect(peripheralRequests).toEqual([]); expect(peripheralRequests).toEqual([]);
}); });
test("SCADA 系统按当前项目读取最新记录并设定模拟异常", async ({ page }, testInfo) => { test("SCADA 调频时间轴按周期主动监测并隐藏设备列表", async ({ page }, testInfo) => {
const latestRequests: string[] = []; const latestRequests: string[] = [];
const simulatedWrites: Array<Record<string, unknown>> = [];
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
await page.route("**/runtime-config.js", (route) => await page.route("**/runtime-config.js", (route) =>
route.fulfill({ route.fulfill({
@@ -185,24 +184,6 @@ test.describe("外围系统", () => {
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] } state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} }); } });
if (path.endsWith("/test-data") && request.method() === "GET") {
return route.fulfill({ json: { items: [], next_cursor: null } });
}
if (path.endsWith("/test-data") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
simulatedWrites.push(body);
return route.fulfill({ status: 201, json: {
...body,
record_id: "50000000-0000-4000-8000-000000000017",
project_id: projectId,
revision: 1,
origin: body.origin,
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
updated_by: "tester"
} });
}
if (path.endsWith("/work-orders")) { if (path.endsWith("/work-orders")) {
return route.fulfill({ json: { items: [], next_cursor: null } }); return route.fulfill({ json: { items: [], next_cursor: null } });
} }
@@ -243,52 +224,140 @@ test.describe("外围系统", () => {
}); });
}); });
const replayRequests: string[] = [];
let replay = {
replay_id: "60000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1,
status: "paused", start_time: "2026-10-22T00:00:00Z", end_time: "2026-10-22T06:00:00Z",
virtual_now: "2026-10-22T01:02:03Z", delivered_until: "2026-10-22T01:02:03Z", processed_until: "2026-10-22T01:02:03Z",
monitoring_interval_seconds: 900, step_pending: false,
monitoring_count: 0, last_monitoring: null as null | Record<string, unknown>,
processed_count: 8, anomaly_count: 0, rules: [{
rule_id: "pressure_drop", device_ids: ["PRESSURE-17"], kind: "relative_change", direction: "decrease",
threshold: 30, unit: "MPa", metric_name: "", lower: null, upper: null,
minimum_samples: 3, window_samples: 4, consecutive_samples: 1, recovery_samples: 2, minimum_devices: 1
}],
devices: [{ device_id: "PRESSURE-17", measurement_unit: "MPa", device_type: "pressure" }],
latest: [{ time: "2026-10-22T01:02:03Z", device_id: "PRESSURE-17", monitored_value: 0.3172,
cleaned_value: 0.315, quality: "corrected", reason: "已配置修正的历史快照" }],
active_events: [], error: null, created_at: "2026-10-22T00:00:00Z", updated_at: "2026-10-22T01:02:03Z"
};
await page.route("http://server.test/api/v1/scada-replays**", (route) => {
const request = route.request();
replayRequests.push(request.method());
const url = new URL(request.url());
if (url.pathname.endsWith("/capabilities")) return route.fulfill({ json: { available: true, can_write: true, reason: null, monitoring_interval_seconds: 900, burst_monitoring_ready: true, burst_monitoring_reason: null, current: replay } });
if (url.pathname.endsWith("/events")) return route.fulfill({ json: { items: [], next_offset: null } });
if (url.pathname.endsWith("/observations")) return route.fulfill({ json: { items: replay.latest.filter((row) => Date.parse(row.time) <= Date.parse(url.searchParams.get("through") ?? "")), through: url.searchParams.get("through") } });
if (url.pathname.endsWith("/commands")) {
const command = request.postDataJSON() as { action: string };
if (command.action === "step") {
const start = Date.parse(replay.start_time);
const step = replay.monitoring_interval_seconds * 1000;
const next = Math.min(Date.parse(replay.end_time), start + (Math.floor((Date.parse(replay.virtual_now) - start) / step) + 1) * step);
replay = { ...replay, revision: replay.revision + 1, status: "paused", virtual_now: new Date(next).toISOString(),
delivered_until: new Date(next).toISOString(), processed_until: new Date(next).toISOString(),
monitoring_count: replay.monitoring_count + 1, last_monitoring: {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: replay.virtual_now, period_end: new Date(next).toISOString(), knowledge_cutoff: new Date(next).toISOString(),
batch_sequence: 9, rule_version: "burst-monitor-v1", status: "normal",
detail: "本周期爆管相关指标均未命中", task_id: null,
pressure: {}, outlet_flow: {}, burst_detection: {}
} };
return route.fulfill({ json: replay });
}
replay = { ...replay, revision: replay.revision + 1, status: command.action === "stop" ? "stopped" : "paused" };
return route.fulfill({ json: replay });
}
return route.fulfill({ json: replay });
});
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click(); await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" }); const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("tab", { name: "SCADA 系统" }).click(); await panel.getByRole("tab", { name: "SCADA 系统" }).click();
await expect(panel.getByRole("table", { name: "SCADA 最新数据表" })).toBeVisible(); await expect(panel.getByText("SCADA 监测演示", { exact: true })).toBeVisible();
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible(); await expect(panel.getByRole("table", { name: "SCADA 回放数据表" })).toHaveCount(0);
await expect(panel.getByText("0.3172 MPa", { exact: true })).toBeVisible(); await expect(panel.getByRole("button", { name: "设定读数" })).toHaveCount(0);
await expect(panel.getByText("0.315 MPa", { exact: true })).toBeVisible();
await expect(panel.getByText("FLOW-09", { exact: true })).toBeVisible();
await expect(panel.getByText("无记录", { exact: true })).toBeVisible();
await expect(panel.getByLabel("SCADA 最新数据概览")).toContainText(/1\s*\/\s*2 个设备/);
expect(latestRequests).toEqual(["324ff7ee-c2d4-5184-a84b-48fb14a2933e"]); expect(latestRequests).toEqual(["324ff7ee-c2d4-5184-a84b-48fb14a2933e"]);
await expect(panel.getByPlaceholder("输入需要演练的异常值")).toHaveCount(0); const dial = panel.getByRole("slider", { name: "SCADA 调频时间轴" });
await expect(dial).toBeVisible();
await expect(dial).toHaveAttribute("data-step-ms", "300000");
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-10-22T01:00:00Z")));
await expect(panel.getByLabel("SCADA 演示状态")).toContainText("15 分钟");
await expect(dial).toHaveCSS("user-select", "none");
expect(await dial.evaluate((element) => getComputedStyle(element, "::after").backgroundImage)).toContain("linear-gradient");
expect(await dial.locator(".scada-fm-scale").evaluate((element) => getComputedStyle(element).maskImage)).toContain("linear-gradient");
await expect(panel.locator(".scada-fm-needle")).toHaveCSS("background-color", "rgb(207, 69, 63)");
await expect.poll(() => dial.getByText("09:00", { exact: true }).evaluate((element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
)).toBeGreaterThan(1.4);
expect(await dial.getByText("08:50", { exact: true }).evaluate((element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
)).toBeCloseTo(1, 2);
await dial.focus();
await dial.press("Home");
await dial.press("ArrowRight");
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time) + 300000));
await dial.press("ArrowLeft");
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
for (let step = 0; step < 12; step += 1) await dial.press("ArrowRight");
await expect.poll(() => dial.getByText("09:00", { exact: true }).evaluate((element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
)).toBeCloseTo(1.55, 2);
const centeredLabel = await dial.getByText("09:00", { exact: true }).boundingBox();
const centeredNeedle = await panel.locator(".scada-fm-needle").boundingBox();
expect(centeredNeedle!.y + centeredNeedle!.height).toBeLessThan(centeredLabel!.y);
await testInfo.attach("scada-tuning-center", {
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-center.png") }), contentType: "image/png"
});
await dial.press("End");
await expect(panel.getByText("该时间尚未完成监测", { exact: true })).toBeVisible();
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
await panel.getByRole("button", { name: "回到监测时间", exact: true }).click();
const dragBox = await dial.boundingBox();
expect(dragBox).not.toBeNull();
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + 45);
await page.mouse.down();
await expect(dial).toHaveAttribute("data-dragging", "true");
await expect(dial).toHaveCSS("cursor", "grabbing");
await page.mouse.move(dragBox!.x + dragBox!.width / 2 - 120, dragBox!.y + 45, { steps: 8 });
await expect(panel.getByText("该时间尚未完成监测", { exact: true })).toBeVisible();
await expect(panel.getByText("时间定位", { exact: true })).toBeVisible();
await testInfo.attach("scada-tuning-dragging", {
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-dragging.png") }), contentType: "image/png"
});
await page.mouse.up();
await expect(dial).toHaveAttribute("data-dragging", "false");
expect(Number(await dial.getAttribute("aria-valuenow")) % 300000).toBe(0);
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
await panel.getByRole("button", { name: "回到监测时间", exact: true }).click();
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
for (const dialWidth of [1280, 375, 320]) {
await page.setViewportSize({ width: dialWidth, height: 1000 });
await dial.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(dialWidth);
await expect.poll(async () => {
const tick = await dial.getByText("09:00", { exact: true }).boundingBox();
const needle = await panel.locator(".scada-fm-needle").boundingBox();
return tick && needle ? Math.abs(needle.x + needle.width / 2 - tick.x - tick.width / 2) : Infinity;
}).toBeLessThan(2);
await testInfo.attach(`scada-tuning-${dialWidth}`, {
body: await page.screenshot({ path: testInfo.outputPath(`scada-tuning-${dialWidth}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 1000 });
await testInfo.attach("scada-overview", { await testInfo.attach("scada-overview", {
body: await page.screenshot({ path: testInfo.outputPath("scada-overview.png") }), contentType: "image/png" body: await page.screenshot({ path: testInfo.outputPath("scada-overview.png") }), contentType: "image/png"
}); });
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("pressure"); await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible(); await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-10-22T01:15:00Z")));
await expect(panel.getByText("FLOW-09", { exact: true })).toHaveCount(0); await expect(panel.getByText("本周期监测正常", { exact: true })).toBeVisible();
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill(""); await testInfo.attach("scada-monitoring-result", {
body: await page.screenshot({ path: testInfo.outputPath("scada-monitoring-result.png") }),
await panel.getByRole("button", { name: "设定读数" }).click(); contentType: "image/png"
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 900 });
await panel.getByPlaceholder("输入需要演练的异常值").scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`scada-peripheral-anomaly-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`scada-${width}.png`) }),
contentType: "image/png"
});
}
await panel.getByPlaceholder("输入需要演练的异常值").fill("0.08");
await panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间").fill("压力突降");
await expectThemeColor(panel.getByPlaceholder("输入需要演练的异常值"), "color", "--ink");
await expectThemeColor(panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间"), "color", "--ink");
await panel.getByRole("button", { name: "保存模拟读数", exact: true }).click();
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("PRESSURE-17");
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("0.08 MPa");
expect(simulatedWrites[0]).toMatchObject({
point_id: "PRESSURE-17",
value: 0.08,
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降"
}); });
}); });