Author SHA1 Message Date
jiang 0801e161ec 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
2026-11-04 14:27:08 +08:00
jiang 78481903d7 test: migrate workbench fixtures to persistent event streams
Static fulfilled SSE bodies closed immediately and made ordinary UI cases reconnect. Serve persistent fixture streams, retain outage and cursor recovery coverage, and update legacy approval and investigation assertions for current disclosures and SVG diagrams.

Validation: 12 related browser scenarios, 877 unit tests, type checking, scoped ESLint, and diff checks. Unrelated SCADA replay changes remain outside this commit.
2026-11-02 18:56:57 +08:00
jiang 0ec74b15b2 fix: restore investigation and appointment task loading
Server responses now include source.replay_context, including null for ordinary tasks. The strict frontend source schema rejected the new field and blocked the shared task snapshot and detail loading path.

Validate replay metadata explicitly and cover investigation, appointment, and scheduled task contracts plus browser loading. Six acceptance regressions failed before the fix; all 877 unit tests and the focused browser loading test passed after it. Lint and type checking passed. The separate approval browser case remains blocked by its reconnect-toast assertion.
2026-11-02 18:37:55 +08:00
jiang 74bf7c64c4 fix: align map control hover spacing
Let control surfaces size to their 40px buttons, padding and borders. Reserve the resulting 50px mobile dock height to keep the map controls clear.

The initial alignment check covered only horizontal spacing and missed the mobile height constraint. Check all four outer edges at desktop and mobile widths, plus the dock gap, to prevent recurrence.
2026-11-02 15:35:11 +08:00
jiang 5f7041ffae fix: recognize class-attached investigation diagram nodes
Generic Container CI/CD / test-build-publish (push) Successful in 1m11s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
2026-11-02 15:23:34 +08:00
39 changed files with 1624 additions and 726 deletions
+3 -1
View File
@@ -32,7 +32,9 @@ Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白
助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 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 回答的展开阅读面板,以及这些界面的子弹窗和提示。
+1 -1
View File
@@ -47,7 +47,7 @@ export function MapToolbar({
"pointer-events-auto p-1",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
isVertical ? "flex w-12 flex-col" : "inline-flex",
isVertical ? "flex w-fit flex-col" : "inline-flex",
className
)}
>
+1 -1
View File
@@ -86,7 +86,7 @@ export function MapZoom({
<div
role="group"
aria-label="地图缩放"
className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
className={cn("pointer-events-auto flex w-fit flex-col gap-2", className)}
>
<div
className={cn(
@@ -42,6 +42,35 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
it.each([null, replayContext])("loads summaries and details with replay_context %j", (context) => {
const value = task();
const detail = { ...value, task_type: taskType, source: { ...value.source, replay_context: context } };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(parseOperationalTaskSummary(summary).source).toHaveProperty("replay_context", context);
expect(parseOperationalTask(detail).source).toHaveProperty("replay_context", context);
});
it.each([
{ ...replayContext, replay_id: "not-a-uuid" },
{ ...replayContext, knowledge_cutoff: "not-a-date" },
{ ...replayContext, batch_sequence: 0 },
{ ...replayContext, batch_sequence: 1.5 },
{ ...replayContext, unexpected: true }
])("rejects invalid replay metadata %j", (context) => {
const value = task();
expect(() => parseOperationalTask({
...value, task_type: taskType, source: { ...value.source, replay_context: context }
})).toThrow();
});
});
it("loads task runs used to resolve a selected report round", async () => {
const run = {
run_id: id,
@@ -13,7 +13,12 @@ const taskSourceSchema = z.object({
external_id: z.string().min(1).nullable(),
external_revision: z.number().int().positive().nullable(),
event_kind: z.string().min(1).nullable(),
deleted_at: workbenchDateTimeSchema.nullish()
deleted_at: workbenchDateTimeSchema.nullish(),
replay_context: z.object({
replay_id: z.string().uuid(),
knowledge_cutoff: workbenchDateTimeSchema,
batch_sequence: z.number().int().positive()
}).strict().nullish()
}).strict();
const taskTriggerSchema = z.object({
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
+4 -3
View File
@@ -44,7 +44,8 @@ const latestScadaReadingSchema = z.object({
transmission_frequency: z.string(),
observed_at: z.string().datetime({ offset: true }).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({
@@ -76,14 +77,14 @@ export class ScadaSystemClient {
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([
this.resolveProjectId(),
getWorkbenchAccessToken(this.getAccessToken)
]);
return latestScadaReadingsSchema.parse(
await requestJson(
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0",
`/api/v1/timeseries/scada-readings/latest?limit=500&offset=${offset}`,
accessToken,
projectId,
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({
id: z.string().trim().min(1),
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_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),
description: z.string().trim().min(1),
occurred_at: z.string().datetime({ offset: true }),
@@ -199,7 +199,12 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
id: alert.id,
title: alert.title,
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,
taskId: alert.source.task_id,
runId: alert.source.run_id
@@ -27,12 +27,12 @@ describe("workbench floating panels", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
mapControlBottom: 126
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
mapControlBottom: 70
});
});
@@ -32,7 +32,7 @@ export const WORKBENCH_LAYOUT = {
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
dockHeight: 50, // 40px buttons + 4px padding and 1px border on each side.
timelineSummaryHeight: 48
}
} as const;
+2 -42
View File
@@ -38,8 +38,7 @@ import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-m
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralTestDataRecord, PeripheralWorkOrder } from "./peripheral-sample/client";
import { buildScadaAnomalyInvestigation } from "./peripheral-sample/scada-anomaly-agent-trigger";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { MapDevPanel } from "./components/map-dev-panel";
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
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() {
openConditionFeedForViewport();
}
@@ -1376,7 +1337,6 @@ function MapWorkbenchContent({
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
onScadaAnomalyTrigger={handleScadaAnomalyAgentTrigger}
/>
<WorkbenchAgentPanels
@@ -1624,7 +1584,7 @@ function MapWorkbenchContent({
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="h-11 min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
className="min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
/>
) : null}
<button
@@ -12,7 +12,6 @@ import { WorkbenchNotice } from "../components/workbench-panel-ui";
import {
PeripheralSampleClient,
type PeripheralContext,
type PeripheralTestDataRecord,
type PeripheralWorkOrder
} from "./client";
@@ -22,14 +21,12 @@ export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
onAgentTrigger,
onScadaAnomalyTrigger
onAgentTrigger
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -72,7 +69,7 @@ export function PeripheralSamplePanel({
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 最新数据</p>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 时段演示</p>
</div>
<Button
size="icon"
@@ -171,11 +168,6 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"}
getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration}
peripheralClient={client}
canSimulate={Boolean(
context?.can_write && context.managed_resources.test_data === "available"
)}
onAgentTrigger={onScadaAnomalyTrigger}
/>
</TabsContent>
</Tabs>
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PeripheralTestDataRecord } from "./client";
import { buildScadaAnomalyInvestigation } from "./scada-anomaly-agent-trigger";
describe("SCADA anomaly Agent trigger", () => {
it("keeps the persisted synthetic reading as the external task source", () => {
const record = {
record_id: "50000000-0000-4000-8000-000000000017",
revision: 3,
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
notes: "[SCADA_ANOMALY] 压力突降"
} as PeripheralTestDataRecord;
expect(buildScadaAnomalyInvestigation(record)).toMatchObject({
kind: "scada_anomaly",
title: "SCADA 异常调查 · PRESSURE-17",
summary: expect.stringContaining("0.08 MPa"),
occurredAt: "2026-10-22T02:03:00Z",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: 3,
event_kind: "scada_anomaly"
},
triggerKind: "event"
});
});
});
@@ -1,27 +0,0 @@
import type { CreateInvestigationInput } from "../hooks/use-operational-tasks";
import type { PeripheralTestDataRecord } from "./client";
export function buildScadaAnomalyInvestigation(
record: PeripheralTestDataRecord
): CreateInvestigationInput {
const valueLabel = record.value === null
? "无有效读数"
: `${record.value}${record.unit ? ` ${record.unit}` : ""}`;
const anomalyDetail = record.notes.replace("[SCADA_ANOMALY]", "").trim();
return {
kind: "scada_anomaly",
title: `SCADA 异常调查 · ${record.point_id}`,
summary: `${record.point_id} 在 ${record.observed_at} 记录模拟异常值 ${valueLabel}。${anomalyDetail}`,
detail: `由外围 SCADA 合成读数触发,点位 ${record.point_id},读数 ${valueLabel},发生时间 ${record.observed_at}。`,
occurredAt: record.observed_at,
priority: "normal",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: record.revision,
event_kind: "scada_anomaly"
},
triggerKind: "event"
};
}
@@ -0,0 +1,18 @@
import type { ScadaReplay } from "../api/scada-replay-client";
/** Keep projection identity when polling only advances the replay clock. */
export function reuseScadaReadings(previous: ScadaReplay["latest"] | null | undefined, next: ScadaReplay["latest"]): ScadaReplay["latest"] {
if (previous?.length !== next.length) return next;
const unchanged = next.every((row, index) => {
const old = previous[index];
return old.device_id === row.device_id && old.time === row.time && old.quality === row.quality
&& old.reason === row.reason && Object.is(old.monitored_value, row.monitored_value)
&& Object.is(old.cleaned_value, row.cleaned_value);
});
return unchanged ? previous : next;
}
export function reuseReplayProjection(previous: ScadaReplay | null, next: ScadaReplay | null): ScadaReplay | null {
if (!next || previous?.replay_id !== next.replay_id) return next;
return { ...next, latest: reuseScadaReadings(previous.latest, next.latest) };
}
@@ -0,0 +1,172 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LatestScadaReadings } from "../api/scada-client";
import type { ScadaReplay } from "../api/scada-replay-client";
import { reuseReplayProjection, reuseScadaReadings } from "./scada-replay-readings";
import { ScadaReplaySection } from "./scada-replay-section";
const useReplay = vi.hoisted(() => vi.fn());
vi.mock("./use-scada-replay", () => ({ useScadaReplay: useReplay }));
vi.mock("./scada-time-tuner", () => ({
ScadaTimeTuner: ({ value, step, onChange }: { value: number; step: number; onChange: (time: number) => void }) => (
<input
aria-label="回看位置测试输入"
type="number"
data-step-ms={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
)
}));
const catalog: LatestScadaReadings = {
items: [{
device_id: "P1",
device_type: "pressure",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: null,
monitored_value: null,
cleaned_value: null
}],
total: 1,
limit: 500,
offset: 0,
observed_count: 0,
latest_observed_at: "2026-10-22T01:00:00Z"
};
function replay(): ScadaReplay {
return {
replay_id: "60000000-0000-4000-8000-000000000001",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
status: "paused",
start_time: "2026-10-22T00:00:00Z",
end_time: "2026-10-22T02:00:00Z",
virtual_now: "2026-10-22T01:00:00Z",
delivered_until: "2026-10-22T01:00:00Z",
processed_until: "2026-10-22T01:00:00Z",
processed_count: 1,
anomaly_count: 0,
devices: catalog.items,
rules: [],
monitoring_interval_seconds: 900,
step_pending: false,
monitoring_count: 0,
last_monitoring: null,
latest: [{
time: "2026-10-22T00:15:00Z",
device_id: "P1",
monitored_value: 0,
cleaned_value: 0,
quality: "valid",
reason: "可信零读数"
}],
active_events: [],
error: null,
created_at: "2026-10-22T00:00:00Z",
updated_at: "2026-10-22T01:00:00Z"
};
}
describe("SCADA replay panel", () => {
let current: ScadaReplay;
let command: ReturnType<typeof vi.fn>;
let observations: ReturnType<typeof vi.fn>;
beforeEach(() => {
current = replay();
command = vi.fn();
observations = vi.fn();
useReplay.mockImplementation(() => ({
replay: current,
capabilities: {
available: true,
can_write: true,
monitoring_interval_seconds: 300,
burst_monitoring_ready: true,
burst_monitoring_reason: null
},
busy: false,
error: null,
events: [],
client: { observations },
command,
change: vi.fn()
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("changes only the timeline position while browsing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), {
target: { value: Date.parse("2026-10-22T00:45:00Z") }
});
expect(screen.getByText("已定位到历史监测时间")).toBeVisible();
expect(observations).not.toHaveBeenCalled();
expect(command).not.toHaveBeenCalled();
});
it("retains new evidence and separates otherwise identical replay runs", () => {
const incoming = structuredClone(current.latest);
expect(reuseScadaReadings(current.latest, incoming)).toBe(current.latest);
const changed = [{ ...incoming[0], reason: "仪表量程待核验" }];
expect(reuseScadaReadings(current.latest, changed)).toBe(changed);
const other = {
...current,
replay_id: "60000000-0000-4000-8000-000000000002",
latest: incoming
};
expect(reuseReplayProjection(current, other)?.latest).toBe(incoming);
});
it("keeps five-minute timeline timestamps separate from the monitoring period", () => {
current.step_pending = true;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveAttribute("data-step-ms", "300000");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("15 分钟");
expect(screen.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
});
it("actively triggers the next monitoring period", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "下一步并监测" }));
expect(command).toHaveBeenCalledWith("step");
});
it("shows the persisted monitoring decision and linked burst investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: "2026-10-22T00:45:00Z",
period_end: "2026-10-22T01:00:00Z",
knowledge_cutoff: "2026-10-22T01:00:00Z",
batch_sequence: 9,
rule_version: "burst-monitor-v1",
status: "suspected_burst",
detail: "爆管侦测、压力下降与出厂边界流量增加相互支持",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: {},
outlet_flow: {},
burst_detection: {}
};
current.monitoring_count = 1;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本周期疑似爆管")).toBeVisible();
expect(screen.getByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).toBeVisible();
expect(screen.getByText("已进入爆管调查与定位流程")).toBeVisible();
});
});
@@ -0,0 +1,456 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
CheckCircle2,
ChevronDown,
CircleHelp,
RotateCcw,
SkipForward,
Square,
TriangleAlert
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import type { LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import type { ScadaReplay, ScadaReplayRule } from "../api/scada-replay-client";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { formatMonitoringInterval, snapTimelineTime } from "./scada-replay-time";
import { ScadaTimeTuner } from "./scada-time-tuner";
import { useScadaReplay } from "./use-scada-replay";
const ACTIVE = new Set(["ready", "paused"]);
const TIMELINE_STEP_MS = 5 * 60_000;
const floorToTimelineStep = (time: number) => Math.floor(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
const ceilToTimelineStep = (time: number) => Math.ceil(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
const STATUS: Record<ScadaReplay["status"], string> = {
ready: "待监测",
paused: "等待下一步",
completed: "区间已完成",
stopped: "本轮已停止",
failed: "处理失败"
};
const MONITORING_STATUS = {
normal: {
title: "本周期监测正常",
icon: CheckCircle2,
className: "bg-emerald-50/80 text-emerald-950 ring-emerald-600/20",
iconClassName: "text-emerald-700"
},
attention: {
title: "本周期需要关注",
icon: TriangleAlert,
className: "bg-amber-50/90 text-amber-950 ring-amber-600/20",
iconClassName: "text-amber-700"
},
insufficient_data: {
title: "本周期数据不足",
icon: CircleHelp,
className: "bg-[var(--surface-control)] text-slate-800 ring-[var(--surface-border)]",
iconClassName: "text-slate-500"
},
suspected_burst: {
title: "本周期疑似爆管",
icon: TriangleAlert,
className: "bg-rose-50/90 text-rose-950 ring-rose-600/20",
iconClassName: "text-rose-700"
}
};
export function ScadaReplaySection({
active,
catalog,
getAccessToken
}: {
active: boolean;
catalog: LatestScadaReadings;
getAccessToken?: WorkbenchAccess;
}) {
const backend = useScadaReplay(active, getAccessToken);
const { replay, capabilities, busy } = backend;
const [start, setStart] = useState(() => localTime(
floorToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()) - 24 * 60 * 60_000)
));
const [end, setEnd] = useState(() => localTime(
ceilToTimelineStep(Date.parse(catalog.latest_observed_at ?? new Date().toISOString()))
));
const [threshold, setThreshold] = useState("30");
const [qualityMetric, setQualityMetric] = useState("");
const [qualityLower, setQualityLower] = useState("");
const [qualityUpper, setQualityUpper] = useState("");
const [selectedTypes, setSelectedTypes] = useState(() => new Set(
catalog.items.map((item) => item.device_type)
));
const [view, setView] = useState<number | null>(null);
const restoredId = useRef<string | undefined>(undefined);
const locked = Boolean(replay && ACTIVE.has(replay.status));
const interval = locked && replay
? replay.monitoring_interval_seconds
: capabilities?.monitoring_interval_seconds;
const intervalLabel = interval ? formatMonitoringInterval(interval) : null;
const startMs = locked && replay ? Date.parse(replay.start_time) : Date.parse(start);
const endMs = locked && replay ? Date.parse(replay.end_time) : Date.parse(end);
const now = replay ? Date.parse(replay.virtual_now) : startMs;
const displayTime = snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
const processedDisplayTime = replay?.processed_until
? floorToTimelineStep(Date.parse(replay.processed_until))
: null;
const future = Boolean(
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
);
const types = [...new Set(catalog.items.map((item) => item.device_type))];
const selectedDevices = catalog.items.filter((item) => selectedTypes.has(item.device_type));
const rules = useMemo(() => {
const groups = new Map<string, typeof catalog.items>();
for (const item of catalog.items) {
if (!selectedTypes.has(item.device_type)) continue;
const key = `${item.device_type}/${item.measurement_unit}/${item.transmission_frequency}`;
groups.set(key, [...groups.get(key) ?? [], item]);
}
const result: ScadaReplayRule[] = [];
for (const devices of groups.values()) {
const device = devices[0];
if (!device.measurement_unit) continue;
if (["pressure", "pipe_flow", "flow"].includes(device.device_type) && Number(threshold) > 0) {
result.push({
rule_id: `change_${result.length}`,
device_ids: devices.map((item) => item.device_id),
kind: "relative_change",
direction: device.device_type === "pressure" ? "decrease" : "both",
threshold: Number(threshold),
unit: device.measurement_unit,
minimum_samples: 3,
window_samples: 4,
consecutive_samples: 1,
recovery_samples: 2,
minimum_devices: 1
});
}
if (device.device_type === "quality" && qualityMetric.trim()
&& (qualityLower.trim() || qualityUpper.trim())) {
result.push({
rule_id: `quality_${result.length}`,
device_ids: devices.map((item) => item.device_id),
kind: "bounds",
unit: device.measurement_unit,
metric_name: qualityMetric.trim(),
lower: qualityLower.trim() ? Number(qualityLower) : null,
upper: qualityUpper.trim() ? Number(qualityUpper) : null,
consecutive_samples: 1,
recovery_samples: 2,
minimum_devices: 1
});
}
}
return result;
}, [catalog, selectedTypes, threshold, qualityMetric, qualityLower, qualityUpper]);
const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs
&& endMs - startMs <= 31 * 24 * 60 * 60_000
&& selectedDevices.length > 0 && selectedDevices.length <= 500
&& Boolean(interval) && rules.length > 0
&& rules.every((rule) => [rule.threshold, rule.lower, rule.upper].every(
(number) => number == null || Number.isFinite(number)
) && (rule.lower == null || rule.upper == null || rule.lower < rule.upper));
useEffect(() => {
if (!replay || restoredId.current === replay.replay_id) return;
restoredId.current = replay.replay_id;
setView(null);
setStart(localTime(Date.parse(replay.start_time)));
setEnd(localTime(Date.parse(replay.end_time)));
setSelectedTypes(new Set(replay.devices.map((device) => device.device_type)));
const relative = replay.rules.find((rule) => rule.kind === "relative_change");
if (relative?.threshold != null) setThreshold(String(relative.threshold));
const water = replay.rules.find((rule) => rule.kind === "bounds" && rule.metric_name);
setQualityMetric(water?.metric_name ?? "");
setQualityLower(water?.lower == null ? "" : String(water.lower));
setQualityUpper(water?.upper == null ? "" : String(water.upper));
}, [replay]);
const advance = () => {
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
return backend.command("step");
}
if (!valid) return;
return backend.change(async () => {
const created = await backend.client.create({
start_time: new Date(startMs).toISOString(),
end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id),
rules
});
return backend.client.command(created, "step");
});
};
const monitoring = replay?.last_monitoring
? MONITORING_STATUS[replay.last_monitoring.status]
: null;
const MonitoringIcon = monitoring?.icon;
const stepDisabled = busy || replay?.step_pending || !interval
|| !capabilities?.available || !capabilities.can_write
|| (!locked && !valid)
|| Boolean(replay && !["ready", "paused", "completed", "stopped"].includes(replay.status));
return (
<WorkbenchSection
title="SCADA 监测演示"
description="时间轴按 5 分钟定位,监测按钮按项目周期触发异常分析。"
icon={<Activity size={15} />}
tone="teal"
bodyClassName="space-y-4"
>
{capabilities && !capabilities.available ? (
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
{capabilities.reason}
</WorkbenchNotice>
) : null}
{backend.error || replay?.error ? (
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
{backend.error ?? replay?.error}
</WorkbenchNotice>
) : null}
{capabilities && !interval ? (
<WorkbenchNotice tone="warning" title="监测频率未配置">
{locked ? "本轮未固定监测频率,请停止后重新建立演示。" : "请先在项目配置中设置监测频率。"}
</WorkbenchNotice>
) : null}
{capabilities && interval && !capabilities.burst_monitoring_ready ? (
<WorkbenchNotice tone="neutral" title="爆管组合监测尚未就绪">
{capabilities.burst_monitoring_reason};仍可演示读取、清洗和基础规则检测。
</WorkbenchNotice>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<DateTimeField
label="演示开始时间"
value={locked && replay ? localTime(startMs) : start}
onChange={setStart}
minuteStep={5}
disabled={locked || busy}
contentClassName="workbench-shadcn-surface"
/>
<DateTimeField
label="演示结束时间"
value={locked && replay ? localTime(endMs) : end}
onChange={setEnd}
minuteStep={5}
disabled={locked || busy}
contentClassName="workbench-shadcn-surface"
/>
</div>
<div
aria-label="SCADA 演示状态"
className="grid grid-cols-3 divide-x divide-[var(--surface-divider)] overflow-hidden rounded-lg bg-[var(--surface-control)] ring-1 ring-inset ring-[var(--surface-divider)]"
>
<Readout label="监测步长" value={intervalLabel ?? "未配置"} />
<Readout label="当前状态" value={replay ? STATUS[replay.status] : "尚未开始"} />
<Readout label="已监测周期" value={String(replay?.monitoring_count ?? 0)} />
</div>
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
<div className="py-1">
<ScadaTimeTuner
start={startMs}
end={endMs}
value={Math.max(startMs, Math.min(endMs, displayTime))}
step={TIMELINE_STEP_MS}
following={false}
browsing={view !== null}
onChange={setView}
/>
</div>
) : null}
<div className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>
{view !== null
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
: "拨动刻度只定位时间,不会自动触发监测"}
</span>
{view !== null ? (
<Button size="sm" variant="ghost" onClick={() => setView(null)}>回到监测时间</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
size="sm"
className="h-10"
disabled={stepDisabled}
onClick={() => void advance()}
>
<SkipForward size={14} />
{replay?.step_pending
? "正在监测本步"
: locked ? "下一步并监测" : replay ? "开始新一轮监测" : "开始并监测"}
</Button>
{replay && ["ready", "paused", "failed"].includes(replay.status) ? (
<Button
size="sm"
variant="ghost"
className="h-10"
disabled={busy || !capabilities?.can_write}
onClick={() => void backend.command("stop")}
>
<Square size={13} />停止本轮
</Button>
) : null}
{replay?.status === "failed" ? (
<Button
size="sm"
variant="outline"
className="h-10"
disabled={busy || !capabilities?.can_write}
onClick={() => void backend.command("retry")}
>
<RotateCcw size={13} />重试本步
</Button>
) : null}
</div>
{replay ? (
<p role="status" aria-label="SCADA 监测进度" className="text-[11px] leading-5 text-muted-foreground tabular-nums">
当前时间 {clock(new Date(displayTime).toISOString())} · 已处理至 {processedDisplayTime === null ? "未开始" : clock(new Date(processedDisplayTime).toISOString())}
{replay.anomaly_count ? ` · 累计检出 ${replay.anomaly_count} 个异常事件` : ""}
</p>
) : null}
{replay?.last_monitoring && monitoring && MonitoringIcon ? (
<section
aria-label="本步监测结果"
role="status"
className={`rounded-lg px-3.5 py-3 ring-1 ring-inset ${monitoring.className}`}
>
<div className="flex items-start gap-2.5">
<MonitoringIcon className={`mt-0.5 shrink-0 ${monitoring.iconClassName}`} size={17} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h4 className="text-xs font-semibold">{monitoring.title}</h4>
<time className="text-[11px] tabular-nums" dateTime={replay.last_monitoring.period_end}>
{clock(replay.last_monitoring.period_end)}
</time>
</div>
<p className="mt-1 text-xs leading-5">{replay.last_monitoring.detail}</p>
{replay.last_monitoring.task_id ? (
<p className="mt-1 text-[11px] font-medium">已进入爆管调查与定位流程</p>
) : null}
</div>
</div>
</section>
) : null}
<details className="border-t border-border pt-3">
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-medium text-foreground">
<ChevronDown size={13} />检测参数
<span className="font-normal text-muted-foreground">
{selectedDevices.length} 个测点 · {rules.length} 项规则
</span>
</summary>
<div className="mt-3 space-y-3">
{locked && replay ? (
<ul className="space-y-1 text-xs text-muted-foreground" aria-label="本轮固定规则">
{replay.rules.map((rule) => (
<li key={rule.rule_id}>
{rule.metric_name ?? typeLabel(replay.devices.find((device) => rule.device_ids.includes(device.device_id))?.device_type ?? rule.rule_id)}
{` · ${rule.device_ids.length} 个测点 · `}
{rule.kind === "bounds"
? `${rule.lower ?? "无下限"} ~ ${rule.upper ?? "无上限"} ${rule.unit}`
: `变化阈值 ${rule.threshold}%`}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{types.map((type) => (
<label key={type} className="flex min-h-10 items-center gap-2 text-xs">
<input
type="checkbox"
checked={selectedTypes.has(type)}
disabled={locked || busy}
onChange={(event) => setSelectedTypes((current) => {
const next = new Set(current);
if (event.target.checked) next.add(type);
else next.delete(type);
return next;
})}
/>
{typeLabel(type)}
</label>
))}
</div>
<label className="block text-xs text-muted-foreground">
压力下降/流量变化阈值(%)
<Input
aria-label="压力流量变化阈值"
className="mt-1 h-10 bg-card"
type="number"
min="0.1"
value={threshold}
disabled={locked || busy}
onChange={(event) => setThreshold(event.target.value)}
/>
</label>
{types.includes("quality") ? (
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground sm:col-span-2">
水质指标名称
<Input
aria-label="水质指标名称"
className="mt-1 h-10 bg-card"
value={qualityMetric}
onChange={(event) => setQualityMetric(event.target.value)}
disabled={locked || busy}
placeholder="例如:化学示踪物浓度"
/>
</label>
<label className="text-xs text-muted-foreground">
水质下限(设备单位)
<Input aria-label="水质下限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityLower} onChange={(event) => setQualityLower(event.target.value)} disabled={locked || busy} />
</label>
<label className="text-xs text-muted-foreground">
水质上限(设备单位)
<Input aria-label="水质上限" className="mt-1 h-10 bg-card" type="number" step="any" value={qualityUpper} onChange={(event) => setQualityUpper(event.target.value)} disabled={locked || busy} />
</label>
</div>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
原始值先存入 TSDB,再完成质量校验与清洗;异常检测使用清洗结果,同时保留原始值供追溯。
</p>
{!valid && !locked ? (
<p className="text-xs text-muted-foreground">
请选择不超过 31 天的有效区间、最多 500 个设备,并配置至少一项有效规则。
</p>
) : null}
</div>
</details>
</WorkbenchSection>
);
}
function Readout({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 px-2.5 py-2.5 sm:px-3">
<p className="truncate text-[10px] leading-4 text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-xs font-semibold text-foreground tabular-nums">{value}</p>
</div>
);
}
function localTime(time: number) {
const date = new Date(time);
return new Date(time - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
function clock(value?: string | null) {
return value
? new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
: "未开始";
}
function typeLabel(value: string) {
return ({ pressure: "压力", pipe_flow: "流量", flow: "流量", quality: "水质" } as Record<string, string>)[value] ?? value;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
const minute = 60_000;
const start = Date.parse("2026-10-08T08:07:00+08:00");
describe("SCADA timeline timestamps", () => {
it("quantizes continuous pointer positions to five-minute SCADA timestamps", () => {
const end = start + 60 * minute;
expect(snapTimelineTime(Date.parse("2026-10-08T08:26:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:25:00+08:00"));
expect(snapTimelineTime(Date.parse("2026-10-08T08:28:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:30:00+08:00"));
});
it("moves keyboard controls between clock-aligned five-minute timestamps", () => {
const end = start + 60 * minute;
expect(moveTimelineTime(start, start, end, 5 * minute, 1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
expect(moveTimelineTime(start + 6 * minute, start, end, 5 * minute, -1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
});
it("reaches exact range endpoints and never leaves the selected range", () => {
const end = start + 22 * minute;
expect(moveTimelineTime(end - minute, start, end, 5 * minute, 1)).toBe(end);
expect(moveTimelineTime(start + minute, start, end, 5 * minute, -1)).toBe(start);
});
it("formats the independent project monitoring period", () => {
expect(formatMonitoringInterval(900)).toBe("15 分钟");
expect(formatMonitoringInterval(91)).toBe("91 秒");
});
});
@@ -0,0 +1,17 @@
/** Return the nearest clock-aligned SCADA timestamp inside the selected range. */
export function snapTimelineTime(time: number, start: number, end: number, step: number): number {
return Math.max(start, Math.min(end, Math.round(time / step) * step));
}
/** Keyboard movement follows clock-aligned timestamps. */
export function moveTimelineTime(time: number, start: number, end: number, step: number, slots: number): number {
const slot = (slots > 0 ? Math.floor(time / step) : Math.ceil(time / step)) + slots;
return Math.max(start, Math.min(end, slot * step));
}
export function formatMonitoringInterval(seconds: number): string {
for (const [unit, label] of [[86400, "天"], [3600, "小时"], [60, "分钟"]] as const) {
if (seconds % unit === 0) return `${seconds / unit} ${label}`;
}
return `${seconds} 秒`;
}
@@ -1,16 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
import { ScadaSystemSection } from "./scada-system-section";
vi.mock("./scada-replay-section", () => ({
ScadaReplaySection: ({ catalog }: { catalog: { items: unknown[] } }) => (
<section data-testid="scada-monitoring-console" data-device-count={catalog.items.length}>
SCADA 监测演示
</section>
)
}));
describe("SCADA peripheral system", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("persists a synthetic abnormal reading before triggering Agent handling", async () => {
it("loads the complete catalog for monitoring without rendering a device list", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -38,55 +44,10 @@ describe("SCADA peripheral system", () => {
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const record: PeripheralTestDataRecord = {
record_id: "50000000-0000-4000-8000-000000000017",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
label: "SCADA 异常模拟 · PRESSURE-17",
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降",
origin: "synthetic",
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
updated_by: "tester"
};
const createTestData = vi.fn().mockResolvedValue(record);
const peripheralClient = {
listTestData: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
createTestData
} as unknown as PeripheralSampleClient;
const onAgentTrigger = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<ScadaSystemSection active refreshGeneration={0} />);
render(
<ScadaSystemSection
active
refreshGeneration={0}
peripheralClient={peripheralClient}
canSimulate
onAgentTrigger={onAgentTrigger}
/>
);
await screen.findByText("PRESSURE-17", { exact: true });
await user.click(screen.getByRole("button", { name: "设定读数" }));
await user.type(screen.getByPlaceholderText("输入需要演练的异常值"), "0.08");
await user.type(screen.getByPlaceholderText("例如:压力突降,偏离近一小时稳定区间"), "压力突降");
await user.click(screen.getByRole("button", { name: "保存并触发 Agent" }));
await waitFor(() => expect(createTestData).toHaveBeenCalledOnce());
expect(createTestData).toHaveBeenCalledWith(expect.objectContaining({
point_id: "PRESSURE-17",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降"
}));
expect(onAgentTrigger).toHaveBeenCalledWith(record);
expect(await screen.findByTestId("scada-monitoring-console")).toHaveAttribute("data-device-count", "1");
expect(screen.queryByRole("table", { name: "SCADA 回放数据表" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "设定读数" })).not.toBeInTheDocument();
});
});
@@ -1,53 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
const REFRESH_INTERVAL_MS = 15_000;
const SCADA_ANOMALY_MARKER = "[SCADA_ANOMALY]";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { ScadaReplaySection } from "./scada-replay-section";
export function ScadaSystemSection({
active,
getAccessToken,
refreshGeneration,
peripheralClient,
canSimulate,
onAgentTrigger
refreshGeneration
}: {
active: boolean;
getAccessToken?: WorkbenchAccess;
refreshGeneration: number;
peripheralClient: PeripheralSampleClient;
canSimulate: boolean;
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
}) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null);
const requestSequenceRef = useRef(0);
const handledRefreshRef = useRef(refreshGeneration);
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null);
const [catalog, setCatalog] = useState<LatestScadaReadings | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);
const [simulationEditorOpen, setSimulationEditorOpen] = useState(false);
const [simulationPointId, setSimulationPointId] = useState("");
const [simulationValue, setSimulationValue] = useState("");
const [simulationObservedAt, setSimulationObservedAt] = useState(() => toLocalInput(new Date()));
const [simulationReason, setSimulationReason] = useState("");
const [simulationBusy, setSimulationBusy] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<PeripheralTestDataRecord[]>([]);
const [triggerRetry, setTriggerRetry] = useState<PeripheralTestDataRecord | null>(null);
const refresh = useCallback(async () => {
if (!active) return;
@@ -58,434 +31,57 @@ export function ScadaSystemSection({
setBusy(true);
try {
const next = await clientRef.current.getLatestReadings(controller.signal);
if (sequence !== requestSequenceRef.current) return;
setSnapshot(next);
while (next.items.length < next.total) {
const page = await clientRef.current.getLatestReadings(controller.signal, next.items.length);
if (!page.items.length) throw new Error("SCADA 设备目录分页不完整,请重新读取");
next.items.push(...page.items);
if (page.latest_observed_at && (!next.latest_observed_at
|| Date.parse(page.latest_observed_at) > Date.parse(next.latest_observed_at))) {
next.latest_observed_at = page.latest_observed_at;
}
}
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setCatalog(next);
setError(null);
setRefreshedAt(new Date());
} catch (nextError) {
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败");
setError(nextError instanceof Error ? nextError.message : "SCADA 设备目录读取失败");
} finally {
if (sequence === requestSequenceRef.current) setBusy(false);
}
}, [active]);
const refreshSimulations = useCallback(async () => {
if (!active || !canSimulate) return;
try {
const page = await peripheralClient.listTestData();
setSimulations(page.items.filter((item) => item.notes.startsWith(SCADA_ANOMALY_MARKER)));
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数读取失败");
}
}, [active, canSimulate, peripheralClient]);
useEffect(() => {
if (!active) return;
void refresh();
const intervalId = window.setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, REFRESH_INTERVAL_MS);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void refresh();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
requestRef.current?.abort();
};
return () => requestRef.current?.abort();
}, [active, refresh]);
useEffect(() => {
if (!active || handledRefreshRef.current === refreshGeneration) return;
handledRefreshRef.current = refreshGeneration;
void refresh();
void refreshSimulations();
}, [active, refresh, refreshGeneration, refreshSimulations]);
useEffect(() => {
void refreshSimulations();
}, [refreshSimulations]);
useEffect(() => {
if (!simulationPointId && snapshot?.items[0]) {
setSimulationPointId(snapshot.items[0].device_id);
}
}, [simulationPointId, snapshot]);
const visibleItems = useMemo(() => {
const query = filter.trim().toLocaleLowerCase("zh-CN");
if (!query) return snapshot?.items ?? [];
return (snapshot?.items ?? []).filter((item) =>
[item.device_id, item.device_type, item.measurement_unit, item.transmission_mode]
.some((value) => value.toLocaleLowerCase("zh-CN").includes(query))
);
}, [filter, snapshot]);
const selectedSimulationDevice = snapshot?.items.find(
(item) => item.device_id === simulationPointId
) ?? null;
const parsedSimulationValue = Number(simulationValue);
const canSaveSimulation = canSimulate && selectedSimulationDevice !== null
&& simulationValue.trim() !== "" && Number.isFinite(parsedSimulationValue)
&& Boolean(simulationObservedAt) && Boolean(simulationReason.trim()) && !simulationBusy;
const saveSimulation = async (triggerAgent: boolean) => {
if (!canSaveSimulation || !selectedSimulationDevice) return;
setSimulationBusy(true);
setSimulationError(null);
setTriggerRetry(null);
try {
const record = await peripheralClient.createTestData({
label: `SCADA 异常模拟 · ${selectedSimulationDevice.device_id}`,
point_id: selectedSimulationDevice.device_id,
observed_at: new Date(simulationObservedAt).toISOString(),
value: parsedSimulationValue,
unit: selectedSimulationDevice.measurement_unit || "未配置",
quality: "valid",
notes: `${SCADA_ANOMALY_MARKER} ${simulationReason.trim()}`
});
setSimulations((current) => [record, ...current.filter((item) => item.record_id !== record.record_id)]);
setSimulationEditorOpen(false);
setSimulationValue("");
setSimulationReason("");
setSimulationObservedAt(toLocalInput(new Date()));
if (triggerAgent) {
if (!onAgentTrigger) throw new Error("Agent 异常处理入口未配置");
try {
await onAgentTrigger(record);
} catch (triggerError) {
setTriggerRetry(record);
setSimulationError(
`模拟读数已保存,但 Agent 任务未建立:${triggerError instanceof Error ? triggerError.message : "触发失败"}`
);
}
}
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数保存失败");
} finally {
setSimulationBusy(false);
}
};
const retryAgentTrigger = async () => {
if (!triggerRetry || !onAgentTrigger) return;
setSimulationBusy(true);
setSimulationError(null);
try {
await onAgentTrigger(triggerRetry);
setTriggerRetry(null);
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "Agent 任务触发失败");
} finally {
setSimulationBusy(false);
}
};
}, [active, refresh, refreshGeneration]);
return (
<div className="space-y-3" lang="zh-CN">
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
</p>
{error ? (
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
{error}。已加载的数据会继续保留。
<WorkbenchNotice role="alert" tone="danger" title="SCADA 演示数据未更新">
{error}。已加载的演示区间仍可继续使用。
</WorkbenchNotice>
) : null}
{simulationError ? (
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成">
<p>{simulationError}</p>
{triggerRetry ? (
<Button
className="mt-2"
size="sm"
variant="outline"
disabled={simulationBusy}
onClick={() => void retryAgentTrigger()}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
重试触发 Agent
</Button>
) : null}
{!catalog && busy ? (
<WorkbenchNotice role="status" tone="neutral" title="正在准备 SCADA 演示">
正在读取项目测点配置和历史数据范围。
</WorkbenchNotice>
) : null}
<section
aria-label="SCADA 最新数据概览"
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border px-1 py-3"
>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p>
<p className="mt-1 text-xl font-semibold leading-7 text-foreground tabular-nums">
{snapshot ? snapshot.observed_count : "—"}
<span className="ml-1 text-xs font-medium text-muted-foreground">
/ {snapshot ? snapshot.total : "—"} 个设备
</span>
</p>
</div>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">最近记录时间</p>
<p className="mt-1 text-sm font-semibold leading-7 text-foreground tabular-nums">
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
</p>
</div>
</section>
<WorkbenchSection
title="最新测点值"
description={refreshedAt
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
icon={<Activity size={15} />}
tone="teal"
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
bodyClassName="p-0"
>
<div className="border-b border-border p-3">
<div className="relative">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="筛选 SCADA 设备"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="筛选设备编号、类型或单位"
className="h-10 bg-card pl-9 text-xs md:text-xs"
/>
</div>
</div>
{!snapshot && busy ? (
<p role="status" className="px-4 py-8 text-center text-xs text-muted-foreground">
正在读取 SCADA 最新数据
</p>
) : visibleItems.length === 0 ? (
<WorkbenchEmptyState
icon={<Database size={16} />}
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
className="m-3"
/>
) : (
<div className="max-h-80 overflow-auto overscroll-contain">
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
<thead className="sticky top-0 z-10 bg-muted text-[11px] text-muted-foreground">
<tr className="border-b border-border">
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item) => (
<tr key={item.device_id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-3 py-2.5 text-left font-medium text-foreground">
<span className="block font-mono text-[12px]">{item.device_id}</span>
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
</span>
</th>
<td className="px-3 py-2.5 text-right font-mono text-foreground tabular-nums">
{formatValue(item.monitored_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-muted-foreground tabular-nums">
{formatValue(item.cleaned_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{item.observed_at ? (
<time dateTime={item.observed_at} className="text-foreground">
{formatObservedTime(item.observed_at)}
</time>
) : (
<StatusBadge tone="neutral">无记录</StatusBadge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{snapshot && snapshot.total > snapshot.items.length ? (
<p className="border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
</p>
) : null}
</WorkbenchSection>
<WorkbenchSection
title="异常读数演练"
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
icon={<Sparkles size={15} />}
tone="warning"
actions={canSimulate ? (
<Button
size="sm"
variant="outline"
aria-expanded={simulationEditorOpen}
onClick={() => setSimulationEditorOpen((current) => !current)}
disabled={simulationBusy || !snapshot?.items.length}
>
{simulationEditorOpen ? <X size={13} /> : <Plus size={13} />}
{simulationEditorOpen ? "收起" : "设定读数"}
</Button>
) : null}
bodyClassName="space-y-3"
>
{!canSimulate ? (
<WorkbenchNotice tone="warning" title="合成读数存储不可用">
需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。
</WorkbenchNotice>
) : null}
{simulationEditorOpen && canSimulate ? (
<div className="border-b border-border pb-4">
<div className="grid gap-3 sm:grid-cols-2">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="SCADA 设备"
value={simulationPointId}
onValueChange={setSimulationPointId}
disabled={simulationBusy}
options={(snapshot?.items ?? []).map((item) => ({
value: item.device_id,
label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}`
}))}
/>
<DateTimeField
contentClassName="workbench-shadcn-surface"
label="模拟发生时间"
value={simulationObservedAt}
onChange={setSimulationObservedAt}
disabled={simulationBusy}
/>
</div>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
模拟读数{selectedSimulationDevice?.measurement_unit
? `(${selectedSimulationDevice.measurement_unit})`
: ""}
<Input
className="mt-1.5 h-10 bg-card text-sm tabular-nums"
inputMode="decimal"
value={simulationValue}
onChange={(event) => setSimulationValue(event.target.value)}
disabled={simulationBusy}
placeholder="输入需要演练的异常值"
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
/>
</label>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
异常说明
<Textarea
className="mt-1.5 min-h-20 bg-card"
value={simulationReason}
onChange={(event) => setSimulationReason(event.target.value)}
disabled={simulationBusy}
placeholder="例如:压力突降,偏离近一小时稳定区间"
/>
</label>
<p className="mt-2 text-[11px] leading-5 text-muted-foreground">
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(true)}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
保存并触发 Agent
</Button>
<Button
size="sm"
variant="outline"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(false)}
>
保存模拟读数
</Button>
</div>
</div>
) : null}
{simulations.length ? (
<ol aria-label="SCADA 模拟异常列表" className="space-y-2">
{simulations.slice(0, 5).map((item) => (
<li
key={item.record_id}
className="flex min-w-0 items-start gap-3 rounded-xl bg-muted px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
>
<StatusBadge tone="warning">模拟异常</StatusBadge>
<div className="min-w-0 flex-1">
<p className="font-mono text-xs font-semibold text-foreground">{item.point_id}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
</p>
<p className="mt-1 break-words text-[11px] leading-4 text-foreground">
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
</p>
</div>
</li>
))}
</ol>
) : canSimulate && !simulationEditorOpen ? (
<p className="text-[11px] leading-5 text-muted-foreground">尚未设定 SCADA 模拟异常。</p>
) : null}
</WorkbenchSection>
{catalog ? (
<ScadaReplaySection
active={active}
getAccessToken={getAccessToken}
catalog={catalog}
/>
) : null}
</div>
);
}
function formatValue(value: number | null, unit: string) {
if (value === null) return "—";
const formatted = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 4 }).format(value);
return `${formatted}${unit ? ` ${unit}` : ""}`;
}
function formatObservedTime(value: string | null) {
if (!value) return "暂无记录";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "时间无效";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(date);
}
function formatClock(value: Date) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(value);
}
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function deviceTypeLabel(value: string) {
return ({
pressure: "压力",
pipe_flow: "流量",
quality: "水质",
demand: "需水量",
reservoir_liquid_level: "水库液位",
tank_liquid_level: "水箱液位",
fixed_pump: "定速泵",
variable_pump: "变频泵"
} as Record<string, string>)[value] ?? value;
}
@@ -0,0 +1,168 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "motion/react";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
const MINUTE = 60_000;
const TIME_FORMAT = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
const SECOND_FORMAT = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
const DATE_FORMAT = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" });
const ACCESSIBLE_FORMAT = new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
});
const DRAG_PUBLISH_MS = 120;
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange }: {
start: number; end: number; value: number; step: number; following: boolean; browsing?: boolean; onChange: (time: number) => void;
}) {
const surface = useRef<HTMLDivElement>(null);
const dragging = useRef<{ x: number; time: number; pointerId: number } | null>(null);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const [width, setWidth] = useState(500);
const reduced = useReducedMotion();
const selectedValue = snapTimelineTime(value, start, end, step);
const position = useMotionValue(selectedValue);
const animation = useRef<{ stop: () => void } | null>(null);
const previewFrame = useRef<number | null>(null);
const publishTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onChangeRef = useRef(onChange);
const displayTime = previewTime ?? selectedValue;
const isDragging = previewTime !== null;
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
if (dragging.current) return;
animation.current?.stop();
if (following && !reduced) animation.current = animate(position, selectedValue, { duration: 0.85, ease: "linear" });
else position.set(selectedValue);
return () => animation.current?.stop();
}, [following, position, reduced, selectedValue]);
useEffect(() => () => {
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
dragging.current = null;
animation.current?.stop();
}, []);
useEffect(() => {
if (!surface.current) return;
const resize = () => setWidth(Math.max(200, surface.current?.clientWidth || 500));
resize();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(200, entry.contentRect.width)));
observer.observe(surface.current);
return () => observer.disconnect();
}, []);
const span = Math.max(1, Math.min(end - start, 6 * step));
const timeFormat = step % MINUTE ? SECOND_FORMAT : TIME_FORMAT;
const major = Math.max(1, Math.ceil((step % MINUTE ? 88 : 64) * span / width / step)) * step;
const fine = step / 5;
const lensRadius = Math.min(120, Math.max(56, width * 0.2));
const base = Math.floor(displayTime / fine) * fine;
const count = Math.ceil(span / fine / 2) + 2;
const marks = useMemo(() => [...new Set([
...Array.from({ length: count * 2 + 1 }, (_, index) => base + (index - count) * fine),
...[start, end].filter((time) => Math.abs(time - base) <= span / 2 + 2 * fine)
])]
.filter((time) => time >= start && time <= end)
.map((time) => <ScadaTick key={time} time={time} width={width} span={span} major={major} minor={step}
endpoint={time === start || time === end} timeFormat={timeFormat}
lensRadius={lensRadius} position={position} />), [base, count, end, fine, lensRadius, major, position, span, start, step, timeFormat, width]);
const clamp = (time: number) => Math.max(start, Math.min(end, time));
const snap = (time: number) => snapTimelineTime(time, start, end, step);
const finishDrag = (pointerId: number) => {
if (dragging.current?.pointerId !== pointerId) return;
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
previewFrame.current = publishTimer.current = null;
const finalTime = snap(position.get());
position.set(finalTime);
dragging.current = null;
setPreviewTime(null);
onChangeRef.current(finalTime);
};
return (
<div className="select-none space-y-2">
<p className="text-center text-[11px] text-muted-foreground">{browsing || isDragging ? "时间定位" : "监测时间"}</p>
<div className="flex items-baseline justify-center gap-2.5 tabular-nums" aria-live="off">
<span className="text-xs text-muted-foreground">{DATE_FORMAT.format(displayTime)}</span>
<span className="text-3xl font-medium tracking-tight text-foreground">{timeFormat.format(displayTime)}</span>
</div>
<div ref={surface} role="slider" tabIndex={0} aria-label="SCADA 调频时间轴"
data-dragging={isDragging}
data-step-ms={step}
aria-description={`方向键每次移动 ${formatMonitoringInterval(step / 1000)},Home 和 End 到区间两端`}
aria-valuemin={start} aria-valuemax={end} aria-valuenow={displayTime}
aria-valuetext={ACCESSIBLE_FORMAT.format(displayTime)}
className="scada-fm-window relative h-28 touch-pan-y overflow-hidden rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onPointerDown={(event) => {
if (event.button !== 0) return;
event.stopPropagation();
if (dragging.current) return;
if (event.pointerType === "mouse") event.preventDefault();
animation.current?.stop();
dragging.current = { x: event.clientX, time: position.get(), pointerId: event.pointerId };
setPreviewTime(snap(position.get()));
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus({ preventScroll: true });
}}
onPointerMove={(event) => {
if (dragging.current?.pointerId === event.pointerId) {
position.set(clamp(dragging.current.time - (event.clientX - dragging.current.x) / width * span));
if (previewFrame.current === null) previewFrame.current = requestAnimationFrame(() => {
previewFrame.current = null;
setPreviewTime(snap(position.get()));
});
if (publishTimer.current === null) publishTimer.current = setTimeout(() => {
publishTimer.current = null;
if (dragging.current) onChangeRef.current(snap(position.get()));
}, DRAG_PUBLISH_MS);
}
}}
onPointerUp={(event) => finishDrag(event.pointerId)} onPointerCancel={(event) => finishDrag(event.pointerId)}
onLostPointerCapture={(event) => finishDrag(event.pointerId)}
onKeyDown={(event) => {
const key = event.key;
const next = key === "Home" ? start : key === "End" ? end
: key === "ArrowLeft" || key === "ArrowDown" ? moveTimelineTime(displayTime, start, end, step, -1)
: key === "ArrowRight" || key === "ArrowUp" ? moveTimelineTime(displayTime, start, end, step, 1)
: key === "PageUp" ? moveTimelineTime(displayTime, start, end, step, major / step)
: key === "PageDown" ? moveTimelineTime(displayTime, start, end, step, -major / step) : null;
if (next !== null) {
event.preventDefault(); event.stopPropagation();
animation.current?.stop();
position.set(next);
onChange(next);
}
}}>
<div aria-hidden="true" className="scada-fm-scale pointer-events-none absolute inset-0">
{marks}
</div>
<span aria-hidden="true" className="scada-fm-needle pointer-events-none absolute bottom-12 left-1/2 top-3 w-0.5 -translate-x-1/2" />
</div>
</div>
);
});
const ScadaTick = memo(function ScadaTick({ time, endpoint, timeFormat, width, span, major, minor, lensRadius, position }: {
time: number; width: number; span: number; major: number; minor: number; lensRadius: number; position: MotionValue<number>;
endpoint: boolean; timeFormat: Intl.DateTimeFormat;
}) {
const isMajor = endpoint || Math.abs(time % major) < 1;
const isMedium = !isMajor && Math.abs(time % minor) < 1;
const offset = useTransform(position, (current) => (time - current) / span * width);
const focus = useTransform(offset, (x) => Math.cos(Math.min(1, Math.abs(x) / lensRadius) * Math.PI / 2) ** 2);
const scaleY = useTransform(focus, (amount) => 1 + amount * 0.25);
const scaleX = useTransform(focus, (amount) => 1 + amount * 0.3);
return <motion.div className="absolute left-1/2 top-5 flex w-20 -translate-x-1/2 flex-col items-center" style={{ x: offset }}>
<motion.span style={{ scaleY, scaleX }}
className={`origin-bottom ${isMajor ? "h-10 w-px bg-foreground/55" : isMedium ? "mt-4 h-6 w-px bg-foreground/35" : "mt-7 h-3 w-px bg-foreground/20"}`} />
{isMajor ? <ScadaTickLabel time={time} focus={focus} timeFormat={timeFormat} /> : null}
</motion.div>;
});
function ScadaTickLabel({ time, focus, timeFormat }: { time: number; focus: MotionValue<number>; timeFormat: Intl.DateTimeFormat }) {
const scale = useTransform(focus, (amount) => 1 + amount * 0.55);
const opacity = useTransform(focus, (amount) => 0.65 + amount * 0.35);
return <motion.span style={{ scale, opacity }} className="mt-2 origin-top text-[11px] text-foreground tabular-nums">{timeFormat.format(time)}</motion.span>;
}
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { WorkbenchAccess } from "../api/request-context";
import { ScadaReplayClient, type ScadaReplay, type ScadaReplayAction, type ScadaReplayCapabilities } from "../api/scada-replay-client";
import { reuseReplayProjection } from "./scada-replay-readings";
export function useScadaReplay(active: boolean, access?: WorkbenchAccess) {
const client = useRef(new ScadaReplayClient(access)).current;
const [capabilities, setCapabilities] = useState<ScadaReplayCapabilities | null>(null);
const [replay, setReplay] = useState<ScadaReplay | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const mutation = useRef(false);
const replayRef = useRef(replay);
replayRef.current = replay;
const refreshSequence = useRef(0);
const refresh = useCallback(async (signal?: AbortSignal) => {
const generation = ++refreshSequence.current;
try {
const cap = await client.capabilities(signal);
if (signal?.aborted || generation !== refreshSequence.current || mutation.current) return;
setCapabilities(cap);
setReplay((previous) => reuseReplayProjection(previous, cap.current));
setError(null);
} catch (nextError) {
if (!signal?.aborted && generation === refreshSequence.current) {
setError(nextError instanceof Error ? nextError.message : "SCADA 回放读取失败");
}
}
}, [client]);
useEffect(() => {
if (!active) return;
const controller = new AbortController();
let timer: number | undefined;
let disposed = false;
const poll = async () => {
if (document.visibilityState === "visible" && !mutation.current) await refresh(controller.signal);
if (!disposed) timer = window.setTimeout(() => void poll(), replayRef.current?.step_pending ? 1000 : 5000);
};
void poll();
const visible = () => { if (document.visibilityState === "visible") void refresh(controller.signal); };
document.addEventListener("visibilitychange", visible);
return () => {
disposed = true;
window.clearTimeout(timer);
controller.abort();
document.removeEventListener("visibilitychange", visible);
};
}, [active, refresh]);
const change = async (operation: () => Promise<ScadaReplay>) => {
if (mutation.current) return;
mutation.current = true;
++refreshSequence.current;
setBusy(true); setError(null);
try {
const next = await operation();
setReplay((previous) => reuseReplayProjection(previous, next));
}
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "回放操作失败"); }
finally { mutation.current = false; setBusy(false); }
};
return {
client, capabilities, replay, error, busy, refresh, change,
command: (action: ScadaReplayAction) => {
if (!replay) return Promise.resolve();
return change(() => client.command(replay, action));
}
};
}
+1 -1
View File
@@ -31,7 +31,7 @@ export type WorkbenchAlert = {
taskType: "scheduled_task" | "analysis_task" | "other_task";
taskId: string;
runId: string;
};
} | { kind: "scada_replay"; replayId: string; ruleId: string; batchSequence: number };
severity?: "warning" | "critical";
occurredAt?: string;
time: string;
@@ -70,6 +70,15 @@ describe("investigation SVG post-processing", () => {
});
describe("investigation diagram presentation", () => {
it("applies recorded states to bare class-attached declarations and ignores partial IDs", () => {
const styled = styleInvestigationDiagram({ title: "核查", mermaid: "flowchart TD\nQUERY:::tjAction --> REVIEW:::tjHuman",
node_refs: [{ node_id: "QUERY", label: "核查", status: "running", evidence_refs: [], action_refs: [] },
{ node_id: "REVIEW", label: "复核", status: "waiting_human", evidence_refs: [], action_refs: [] },
{ node_id: "QUER", label: "无效引用", status: "completed", evidence_refs: [], action_refs: [] }] }, true);
expect(styled).toContain("class QUERY tjRunning;");
expect(styled).toContain("class REVIEW tjWaitingHuman;");
expect(styled).not.toContain("class QUER ");
});
it("retains source relationships and only styles declared business references", () => {
const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]';
const styled = styleInvestigationDiagram({
@@ -166,7 +166,7 @@ export function paintInvestigationDiagramStates(root: Element, diagram: Investig
export function styleInvestigationDiagram(diagram: InvestigationDiagram, running = false) {
const assignments = diagram.node_refs.flatMap((node) => {
const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test(
const inSource = new RegExp(`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`, "m").test(
diagram.mermaid
);
if (!inSource) return [];
+10
View File
@@ -33,6 +33,16 @@ describe("DateTimeField", () => {
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"])(
"rejects invalid or unexpected source value %s",
async (value) => {
+6 -3
View File
@@ -14,6 +14,7 @@ export interface DateTimeFieldProps {
onChange: (value: string) => void;
disabled?: boolean;
contentClassName?: string;
minuteStep?: number;
}
interface DateTimeParts {
@@ -122,7 +123,9 @@ function moveDay(parts: DateTimeParts, amount: number): DateTimeParts {
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 [open, setOpen] = useState(false);
const [draft, setDraft] = useState<DateTimeParts>(
@@ -133,7 +136,7 @@ export function DateTimeField({ label, value, onChange, disabled = false, conten
const selectedDayRef = useRef<HTMLButtonElement>(null);
const focusDayAfterNavigation = useRef(false);
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 parsed = parseDateTimeValue(nextValue);
@@ -364,7 +367,7 @@ export function DateTimeField({ label, value, onChange, disabled = false, conten
contentClassName={contentClassName}
label="分钟"
value={pad(draft.minute)}
options={MINUTE_OPTIONS}
options={minuteOptions}
onValueChange={(minute) => {
setDraft({ ...draft, minute: Number(minute) });
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 {
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; }
}
@@ -1,5 +1,6 @@
import { responsesFixture } from "./support/responses-fixture";
import { expect, test } from "@playwright/test";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
import { writeFile } from "node:fs/promises";
import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
@@ -108,7 +109,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
tasks: [summary(task)], notifications, generated_at: now
} }));
await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" }));
await mockWorkbenchEventStream(page);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => {
if (route.request().method() === "POST") {
expect(route.request().postDataJSON().expected_version).toBe(1);
@@ -289,9 +290,12 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
await expect(fullscreen).toHaveCount(0);
await diagram.locator(".node").filter({ hasText: "上游监测" }).click({ timeout: 5000 });
await expect(panel.getByLabel("节点详情 上游监测")).toBeVisible();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).click();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).focus();
const upstreamLink = diagram.getByRole("button", { name: /^上游监测 · .* · 1 条依据$/ });
await upstreamLink.click();
await expect(upstreamLink).toHaveAttribute("aria-pressed", "false");
await upstreamLink.focus();
await page.keyboard.press("Enter");
await expect(upstreamLink).toHaveAttribute("aria-pressed", "true");
await expect(panel.getByLabel("节点详情 上游监测")).toContainText("上游监测数据质量已核对");
await testInfo.attach("dynamic-investigation-desktop", {
body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png"
@@ -20,7 +20,7 @@ for (const width of [1440, 375, 320]) {
const { InvestigationDiagramView } = await import("/src/features/workbench/components/investigation-diagram-view.tsx");
const diagram = {
title: "调查节点状态",
mermaid: 'flowchart TD\nP[待启动核查] --> R[核对监测]:::tjAction\nR --> C[核查完成]:::tjHuman\nR --> W[等待系统结果]\nR --> H[等待人工复核]\nC --> F[查询失败]\nC --> S[未选用分支]\nW --> X[取消分支]\nH --> U[状态未记录]\nstyle R fill:#FFF6E8,stroke:#C4A168;',
mermaid: 'flowchart TD\nP[待启动核查] --> R[核对监测]:::tjAction\nR --> C[核查完成]:::tjHuman\nR --> W[等待系统结果]\nR --> H[等待人工复核]\nC --> F:::tjHuman\nC --> S[未选用分支]\nW --> X[取消分支]\nH --> U:::tjAction\nstyle R fill:#FFF6E8,stroke:#C4A168;',
node_refs: [
{ node_id: "P", label: "待启动核查", status: "pending", evidence_refs: [], action_refs: [] },
{ node_id: "R", label: "核对监测", status: "running", evidence_refs: ["e1"], action_refs: ["50000000-0000-4000-8000-000000000001"] },
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
@@ -189,7 +190,7 @@ test("调查任务执行记录自动刷新并可创建调试会话追加消息",
notifications: [],
generated_at: now
} }));
await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" }));
await mockWorkbenchEventStream(page);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }));
await page.route("http://server.test/api/v1/operational-tasks**", (route) => {
const path = new URL(route.request().url()).pathname;
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
for (const width of [1280, 375, 320]) {
test(`map control hover borders have symmetric spacing at ${width}px`, async ({ page }, testInfo) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.route("**/city-model/manifest.json", (route) => route.fulfill({ status: 404 }));
await page.setViewportSize({ width, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const zoomControls = page.getByRole("group", { name: "地图缩放", exact: true });
const toolbar = page.getByRole("navigation", { name: "地图工具", exact: true });
await expect(zoomControls).toBeVisible();
await expect(toolbar).toBeVisible();
await expect(zoomControls.getByRole("button").first()).toBeEnabled();
const surfaces = zoomControls.locator(".acrylic-control").or(toolbar);
await expect(surfaces).toHaveCount(3);
for (const surface of await surfaces.all()) {
const buttons = surface.getByRole("button");
for (const button of await buttons.all()) {
await button.hover();
const size = await button.boundingBox();
expect(size!.height).toBe(40);
if (width >= 1024 || (await zoomControls.getByRole("button").and(button).count()) > 0) {
expect(size!.width).toBe(40);
}
}
// Check all four outer edges, including the final row's bottom spacing.
const insets = await surface.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const buttons = [...element.querySelectorAll("button")].map((button) => button.getBoundingClientRect());
return {
top: Math.min(...buttons.map((button) => button.top)) - bounds.top,
bottom: bounds.bottom - Math.max(...buttons.map((button) => button.bottom)),
left: Math.min(...buttons.map((button) => button.left)) - bounds.left,
right: bounds.right - Math.max(...buttons.map((button) => button.right))
};
});
expect.soft(insets).toEqual({ top: 5, bottom: 5, left: 5, right: 5 });
}
if (width < 1024) {
const zoomBounds = await zoomControls.boundingBox();
const toolbarBounds = await toolbar.boundingBox();
expect(toolbarBounds!.height).toBe(50);
expect(toolbarBounds!.y - (zoomBounds!.y + zoomBounds!.height)).toBe(8);
}
await zoomControls.getByRole("button", { name: "放大", exact: true }).hover();
await page.screenshot({ path: testInfo.outputPath("map-control-hover.png") });
});
}
+21 -23
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Locator } from "@playwright/test";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
import type {
OperationalTask,
OperationalTaskAction,
@@ -207,9 +208,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
generated_at: "2026-09-23T08:30:00Z"
} });
});
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
const eventStream = await mockWorkbenchEventStream(page);
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
@@ -254,6 +253,10 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
return route.fulfill({ status: 404, json: {} });
});
const openAction = async (panel: Locator) => {
const disclosure = panel.locator(`[data-review-action="${actionId}"]`);
if (await disclosure.getAttribute("open") === null) await disclosure.locator("summary").first().click();
};
const openTask = async () => {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
@@ -261,6 +264,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
if (await expandButton.isVisible()) await expandButton.click();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await conditionPanel.getByText("阀门调节审批", { exact: true }).first().click();
await openAction(conditionPanel);
return conditionPanel;
};
@@ -273,17 +277,8 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await expect(page.getByText("当前无待处理事项", { exact: true }).first()).toBeVisible();
await expect(page.getByText("业务告警来源:未配置", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
const reconnectNotice = page.locator("[data-sonner-toast]").filter({
hasText: "业务同步正在重连"
});
await expect(reconnectNotice).toBeVisible();
await expect(reconnectNotice).toContainText("当前数据可能陈旧,连接恢复后将自动更新。");
await expect(page.getByText("正在重连,业务数据可能陈旧", { exact: true })).toHaveCount(0);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("workbench-reconnect-notice", {
body: await page.screenshot({ path: testInfo.outputPath("workbench-reconnect-notice.png") }),
contentType: "image/png"
});
await expect.poll(() => eventStream.cursors.length).toBe(1);
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
let conditionPanel = await openTask();
await expect(conditionPanel.getByRole("navigation", { name: "工况任务分类" })).toBeVisible();
await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
@@ -299,6 +294,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await refreshingButton.evaluate((button: HTMLButtonElement) => button.click());
expect(snapshotRequests).toBe(2);
await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 });
expect(eventStream.cursors).toEqual(["0"]);
expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900);
await expect(conditionPanel.getByRole("button", { name: "配置定时流程" })).toHaveCount(0);
await expect(conditionPanel.getByRole("button", { name: "创建定时流程" })).toHaveCount(0);
@@ -317,13 +313,15 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await expect(appointmentDetail.getByText("联调测试点东侧", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0);
await page.waitForTimeout(3_500);
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: "/tmp/tjwater-operational-ui-review/task-categories-desktop.png" });
await testInfo.attach("task-categories-desktop", {
body: await conditionPanel.screenshot(), contentType: "image/png"
});
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
expect(scheduleRequests).toBe(0);
await expect(conditionPanel.getByText("上一轮报告,当前任务待人工", { exact: true })).toBeVisible();
await expect(conditionPanel.getByRole("region", { name: "任务报告", exact: true }).getByText("上一轮报告", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText("待人工审核", { exact: true })).toBeVisible();
await openAction(conditionPanel);
await expect(conditionPanel.getByText(/第\s*[12]\s*代|generation/i)).toHaveCount(0);
const actionCard = conditionPanel.locator(`[data-action-id="${actionId}"]`);
await expect(actionCard.getByText("命令类型", { exact: true })).toBeVisible();
@@ -372,7 +370,9 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
const reconcileButton = conditionPanel.getByRole("button", { name: "核对执行回执" });
await expect(reconcileButton).toBeVisible();
await reconcileButton.click();
const receipt = conditionPanel.getByRole("region", { name: "执行回执" });
const progress = conditionPanel.getByRole("region", { name: "处置进展", exact: true });
await progress.locator("summary").filter({ hasText: "设备命令 · 执行成功" }).click();
const receipt = progress.getByRole("region", { name: "执行回执" });
await expect(receipt).toContainText("SCADA-RECEIPT-20260923-01");
await expect(receipt).toContainText("已完成");
await expect(receipt).toContainText("V-102 开度已调整至 35%");
@@ -420,9 +420,7 @@ test("后端没有调查任务时显示空态,分类标签按压不缩放", as
tasks: [], notifications: [], generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await mockWorkbenchEventStream(page);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
await page.goto("/");
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
const now = new Date().toISOString();
const id = "72000000-0000-4000-8000-000000000001";
const appointmentId = "72000000-0000-4000-8000-000000000002";
const summary: OperationalTaskSummary = {
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id,
title: "凤阳爆管调查加载核验", summary: "核对调查任务详情",
source: { kind: "external_system", external_id: id, external_revision: 1,
event_kind: "burst_report", replay_context: null },
trigger: { kind: "event", occurred_at: now, detail: "核对调查任务详情" },
scheduled_for: null, agent_session_id: null, agent_lease_expires_at: null,
current_plan_revision: null, current_report_revision: null, created_at: now, updated_at: now
};
const appointment: OperationalTaskSummary = {
...summary, task_id: appointmentId, task_type: "external_work_order", workflow: "external-work-order",
lifecycle_status: "open", status: "running", priority: "normal", execution_request_id: appointmentId,
title: "凤阳预约维修加载核验", summary: "核对预约任务详情", scheduled_for: now,
source: { ...summary.source, external_id: `peripheral_sample:${appointmentId}`, event_kind: "general" },
trigger: { kind: "external_sync", occurred_at: now, detail: "核对预约任务详情" }
};
const summaries = [summary, appointment];
const tasks: OperationalTask[] = summaries.map((item) => ({
...item, latest_plan: null, latest_report: null, evidence: [], actions: [],
permissions: { can_publish_artifacts: false, can_propose_actions: false,
can_sync_external_feedback: false, can_decide_work_orders: false,
can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false },
work_order_snapshot: item.task_type === "external_work_order" ? {
work_order_id: appointmentId, revision: 1, title: item.title, summary: item.summary,
status: "open", priority: "normal", assignee: "凤阳维修组", scheduled_at: now,
work_order_type: "general", location: "凤阳核验点", scenario_code: null,
origin: "system_data", updated_at: now
} : null
}));
const runtime = {
availability: "not_configured", source: "none", scenario_availability: "not_configured",
artifact_availability: "not_configured", schedule_availability: "not_configured",
work_order_availability: "not_configured", alert_availability: "not_configured",
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
};
await mockWorkbenchContext(page, runtime);
await mockAgentApi(page);
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime,
tasks: summaries, notifications: [], generated_at: now
} }));
await page.route("https://tjwater-api.invalid/api/v1/operational-tasks/*", (route) => {
const taskId = new URL(route.request().url()).pathname.split("/").at(-1);
const task = tasks.find((item) => item.task_id === taskId);
return route.fulfill({ status: task ? 200 : 404, json: task ?? {} });
});
await page.goto("/");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
await panel.getByRole("button", { name: "展开工况任务" }).click();
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(summary.title, { exact: true }).first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary);
await testInfo.attach("investigation-loaded", { body: await panel.screenshot(), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click();
await panel.getByText(appointment.title, { exact: true }).first().click();
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
await expect(detail).toContainText("核对预约任务详情");
await expect(detail).toContainText("凤阳维修组");
await expect(detail).toContainText("凤阳核验点");
await testInfo.attach("appointment-loaded", { body: await panel.screenshot(), contentType: "image/png" });
});
+146 -76
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
import { mockAgentApi } from "./support/mock-agent-api";
import { expectThemeColor, readRenderedContrast } from "./support/theme-colors";
@@ -161,9 +162,8 @@ test.describe("外围系统", () => {
expect(peripheralRequests).toEqual([]);
});
test("SCADA 系统按当前项目读取最新记录并设定模拟异常", async ({ page }, testInfo) => {
test("SCADA 调频时间轴按周期主动监测并隐藏设备列表", async ({ page }, testInfo) => {
const latestRequests: string[] = [];
const simulatedWrites: Array<Record<string, unknown>> = [];
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
@@ -184,24 +184,6 @@ test.describe("外围系统", () => {
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
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")) {
return route.fulfill({ json: { items: [], next_cursor: null } });
}
@@ -242,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.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("tab", { name: "SCADA 系统" }).click();
await expect(panel.getByRole("table", { name: "SCADA 最新数据表" })).toBeVisible();
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
await expect(panel.getByText("0.3172 MPa", { exact: true })).toBeVisible();
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 个设备/);
await expect(panel.getByText("SCADA 监测演示", { exact: true })).toBeVisible();
await expect(panel.getByRole("table", { name: "SCADA 回放数据表" })).toHaveCount(0);
await expect(panel.getByRole("button", { name: "设定读数" })).toHaveCount(0);
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", {
body: await page.screenshot({ path: testInfo.outputPath("scada-overview.png") }), contentType: "image/png"
});
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("pressure");
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
await expect(panel.getByText("FLOW-09", { exact: true })).toHaveCount(0);
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("");
await panel.getByRole("button", { name: "设定读数" }).click();
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] 压力突降"
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-10-22T01:15:00Z")));
await expect(panel.getByText("本周期监测正常", { exact: true })).toBeVisible();
await testInfo.attach("scada-monitoring-result", {
body: await page.screenshot({ path: testInfo.outputPath("scada-monitoring-result.png") }),
contentType: "image/png"
});
});
@@ -755,9 +825,7 @@ test.describe("外围系统", () => {
generated_at: "2026-09-23T08:30:00Z"
} })
);
await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
);
await mockWorkbenchEventStream(page);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const url = new URL(request.url());
@@ -805,33 +873,37 @@ test.describe("外围系统", () => {
await expect(conditionPanel).toBeVisible();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible();
await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible();
await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible();
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible();
const expectSavedPlan = async (taskPanel: typeof conditionPanel) => {
const diagram = taskPanel.getByRole("region", { name: "调查脉络", exact: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg g.node")).toHaveCount(plan.nodes.length);
await expect(diagram.locator("svg .flowchart-link")).toHaveCount(plan.edges.length);
await expect(diagram.getByLabel("图示状态说明")).toContainText("未标注状态");
await expect(diagram.locator("svg g.node").filter({ hasText: "形成调查结论" })).toBeVisible();
return diagram;
};
const diagram = await expectSavedPlan(conditionPanel);
await expect(conditionPanel.getByText(/并行处理/)).toHaveCount(0);
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(conditionPanel.getByText("等待水箱清洗记录", { exact: true }).first()).toBeVisible();
await expect(diagram.locator("svg g.node").filter({ hasText: "核对水箱记录" })).toBeVisible();
const report = conditionPanel.getByRole("region", { name: "任务报告" });
await expect(report.getByText(/疑似二次供水环节问题,待现场核验/)).toBeVisible();
await expect(report.getByText("完整调查报告", { exact: true })).toHaveCount(0);
await expect(report.getByText(/结论边界/)).toBeVisible();
await expect(conditionPanel.getByText("TJWater operational task", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByText("browser-session-1", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await diagram.scrollIntoViewIfNeeded();
await expect(conditionPanel.getByText("前置工作与条件", { exact: true })).toHaveCount(0);
await conditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await diagram.scrollIntoViewIfNeeded();
await expect(page.getByText("已建立水质投诉调查", { exact: true })).toBeHidden({ timeout: 10_000 });
await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" });
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("water-quality-condition-task-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
contentType: "image/png"
});
await conditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded();
await diagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded();
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
contentType: "image/png"
@@ -849,20 +921,18 @@ test.describe("外围系统", () => {
}
await responsiveConditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible();
const responsiveWorkflowStep = responsiveConditionPanel.locator('[data-execution-node="upstream"]');
const responsiveDiagram = await expectSavedPlan(responsiveConditionPanel);
const responsiveWorkflowStep = responsiveDiagram.locator("svg g.node").filter({ hasText: "校验上游水质监测" });
await responsiveWorkflowStep.scrollIntoViewIfNeeded();
await expect(responsiveWorkflowStep).toBeVisible();
await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13);
await expect(responsiveConditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible();
await responsiveDiagram.scrollIntoViewIfNeeded();
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
await page.waitForTimeout(350);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`water-quality-condition-task-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
contentType: "image/png"
});
await responsiveConditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded();
await responsiveDiagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
@@ -900,7 +970,7 @@ test.describe("外围系统", () => {
await recovered.getByRole("button", { name: /调查任务/ }).click();
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
await taskRow.click();
await expect(recovered.getByText(/历史流程按原始依赖展示/)).toBeVisible();
await expectSavedPlan(recovered);
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
});
+3 -5
View File
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { mockWorkbenchEventStream } from "./mock-workbench-event-stream";
const emptyRuntimeSnapshot = {
availability: "not_configured",
@@ -27,6 +28,7 @@ export async function mockScadaApi(
analysisRuns: Record<string, unknown> = {},
analysisHistory: unknown[] = []
) {
await mockWorkbenchEventStream(page);
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
await page.route("https://tjwater-api.invalid/**", async (route) => {
if (route.request().method() === "OPTIONS") {
@@ -111,11 +113,7 @@ export async function mockScadaApi(
return;
}
if (url.pathname === "/api/v1/workbench/events") {
await route.fulfill({
contentType: "text/event-stream",
headers: { ...corsHeaders, "Cache-Control": "no-cache" },
body: ": test keepalive\n\n"
});
await route.fallback();
return;
}
if (url.pathname === "/api/v1/workbench/analysis-runs") {
@@ -0,0 +1,48 @@
import { createServer, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import type { Page } from "@playwright/test";
const fixtures = new WeakMap<Page, { cursors: string[] }>();
// route.fulfill() closes an SSE response after its body. UI fixtures need a
// real open response so the application stays live until the page disconnects.
export async function mockWorkbenchEventStream(page: Page) {
const existing = fixtures.get(page);
if (existing) return existing;
const clients = new Set<ServerResponse>();
const cursors: string[] = [];
const server = createServer((request, response) => {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID, X-Project-Id");
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
if (request.method === "OPTIONS") {
response.writeHead(204).end();
return;
}
cursors.push(new URL(request.url!, "http://localhost").searchParams.get("after") ?? "");
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
response.write(": fixture connected\n\n");
clients.add(response);
response.on("close", () => clients.delete(response));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address() as AddressInfo;
page.on("close", () => {
for (const client of clients) client.destroy();
server.close();
server.closeAllConnections();
});
await page.route("**/api/v1/workbench/events**", (route) => {
const query = new URL(route.request().url()).search;
return route.fulfill({ status: 307, headers: {
"Access-Control-Allow-Origin": "*",
"Location": `http://127.0.0.1:${port}/events${query}`
} });
});
const fixture = { cursors };
fixtures.set(page, fixture);
return fixture;
}
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
type SyncProbe = {
available: boolean;
@@ -14,6 +15,27 @@ declare global {
interface Window { syncProbe: SyncProbe }
}
test("healthy UI fixtures keep one event connection open across the warning window and page reload", async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);
const stream = await mockWorkbenchEventStream(page);
const now = new Date("2026-10-12T02:00:00Z");
await page.clock.install({ time: now });
await page.clock.pauseAt(now);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect.poll(() => stream.cursors.length).toBe(1);
await page.clock.runFor(31_000);
expect(stream.cursors).toEqual(["0"]);
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
await expect.poll(() => stream.cursors.length).toBe(2);
await page.clock.runFor(16_000);
expect(stream.cursors).toEqual(["0", "0"]);
await expect(page.getByText("业务同步正在重连", { exact: true })).toHaveCount(0);
});
test("scheduled renewals stay silent while a sustained outage warns and recovers", async ({ page }) => {
await mockWorkbenchContext(page);
await mockAgentApi(page);