diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md index 8c6da27..b009788 100644 --- a/docs/style-design-guide.md +++ b/docs/style-design-guide.md @@ -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 回答的展开阅读面板,以及这些界面的子弹窗和提示。 diff --git a/src/features/workbench/api/scada-client.ts b/src/features/workbench/api/scada-client.ts index 1fb7095..a9c0444 100644 --- a/src/features/workbench/api/scada-client.ts +++ b/src/features/workbench/api/scada-client.ts @@ -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 { + async getLatestReadings(signal?: AbortSignal, offset = 0): Promise { 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 diff --git a/src/features/workbench/api/scada-replay-client.ts b/src/features/workbench/api/scada-replay-client.ts new file mode 100644 index 0000000..85d9906 --- /dev/null +++ b/src/features/workbench/api/scada-replay-client.ts @@ -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; +export type ScadaReplayRule = z.infer; +export type ScadaReplayEvent = z.infer; +export type ScadaMonitoringResult = z.infer; +export type ScadaReplayCapabilities = z.infer; +export type ScadaReplayAction = "step" | "stop" | "retry"; + +export class ScadaReplayClient { + private projectId: Promise | 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; + } +} diff --git a/src/features/workbench/api/workbench-runtime-client.ts b/src/features/workbench/api/workbench-runtime-client.ts index 59c395e..f3d7c84 100644 --- a/src/features/workbench/api/workbench-runtime-client.ts +++ b/src/features/workbench/api/workbench-runtime-client.ts @@ -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 diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index d922564..5feb647 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -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} /> void; getAccessToken?: WorkbenchAccess; onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise; - onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise; }) { const clientRef = useRef(new PeripheralSampleClient(getAccessToken)); const client = clientRef.current; @@ -72,7 +69,7 @@ export function PeripheralSamplePanel({

外围系统

-

工单协同与 SCADA 最新数据

+

工单协同与 SCADA 时段演示

+ ) : null} + + +
+ + {replay && ["ready", "paused", "failed"].includes(replay.status) ? ( + + ) : null} + {replay?.status === "failed" ? ( + + ) : null} +
+ + {replay ? ( +

+ 当前时间 {clock(new Date(displayTime).toISOString())} · 已处理至 {processedDisplayTime === null ? "未开始" : clock(new Date(processedDisplayTime).toISOString())} + {replay.anomaly_count ? ` · 累计检出 ${replay.anomaly_count} 个异常事件` : ""} +

+ ) : null} + + {replay?.last_monitoring && monitoring && MonitoringIcon ? ( +
+
+ +
+
+

{monitoring.title}

+ +
+

{replay.last_monitoring.detail}

+ {replay.last_monitoring.task_id ? ( +

已进入爆管调查与定位流程

+ ) : null} +
+
+
+ ) : null} + +
+ + 检测参数 + + {selectedDevices.length} 个测点 · {rules.length} 项规则 + + +
+ {locked && replay ? ( +
    + {replay.rules.map((rule) => ( +
  • + {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}%`} +
  • + ))} +
+ ) : null} +
+ {types.map((type) => ( + + ))} +
+ + {types.includes("quality") ? ( +
+ + + +
+ ) : null} +

+ 原始值先存入 TSDB,再完成质量校验与清洗;异常检测使用清洗结果,同时保留原始值供追溯。 +

+ {!valid && !locked ? ( +

+ 请选择不超过 31 天的有效区间、最多 500 个设备,并配置至少一项有效规则。 +

+ ) : null} +
+
+ + ); +} + +function Readout({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +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)[value] ?? value; +} diff --git a/src/features/workbench/peripheral-sample/scada-replay-time.test.ts b/src/features/workbench/peripheral-sample/scada-replay-time.test.ts new file mode 100644 index 0000000..9cd097e --- /dev/null +++ b/src/features/workbench/peripheral-sample/scada-replay-time.test.ts @@ -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 秒"); + }); +}); diff --git a/src/features/workbench/peripheral-sample/scada-replay-time.ts b/src/features/workbench/peripheral-sample/scada-replay-time.ts new file mode 100644 index 0000000..37ed8fd --- /dev/null +++ b/src/features/workbench/peripheral-sample/scada-replay-time.ts @@ -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} 秒`; +} diff --git a/src/features/workbench/peripheral-sample/scada-system-section.test.tsx b/src/features/workbench/peripheral-sample/scada-system-section.test.tsx index bcab452..208556c 100644 --- a/src/features/workbench/peripheral-sample/scada-system-section.test.tsx +++ b/src/features/workbench/peripheral-sample/scada-system-section.test.tsx @@ -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[] } }) => ( +
+ SCADA 监测演示 +
+ ) +})); + 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(); - render( - - ); - - 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(); }); }); diff --git a/src/features/workbench/peripheral-sample/scada-system-section.tsx b/src/features/workbench/peripheral-sample/scada-system-section.tsx index 0eaa027..b48dd3a 100644 --- a/src/features/workbench/peripheral-sample/scada-system-section.tsx +++ b/src/features/workbench/peripheral-sample/scada-system-section.tsx @@ -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; }) { const clientRef = useRef(new ScadaSystemClient(getAccessToken)); const requestRef = useRef(null); const requestSequenceRef = useRef(0); const handledRefreshRef = useRef(refreshGeneration); - const [snapshot, setSnapshot] = useState(null); + const [catalog, setCatalog] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [filter, setFilter] = useState(""); - const [refreshedAt, setRefreshedAt] = useState(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(null); - const [simulations, setSimulations] = useState([]); - const [triggerRetry, setTriggerRetry] = useState(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 (
-

- 当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。 -

{error ? ( - - {error}。已加载的数据会继续保留。 + + {error}。已加载的演示区间仍可继续使用。 ) : null} - {simulationError ? ( - -

{simulationError}

- {triggerRetry ? ( - - ) : null} + {!catalog && busy ? ( + + 正在读取项目测点配置和历史数据范围。 ) : null} - -
-
-

已取得最新记录

-

- {snapshot ? snapshot.observed_count : "—"} - - / {snapshot ? snapshot.total : "—"} 个设备 - -

-
-
-

最近记录时间

-

- {formatObservedTime(snapshot?.latest_observed_at ?? null)} -

-
-
- - } - tone="teal" - meta={busy ? 更新中 : null} - bodyClassName="p-0" - > -
-
-
-
- - {!snapshot && busy ? ( -

- 正在读取 SCADA 最新数据 -

- ) : visibleItems.length === 0 ? ( - } - title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"} - description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"} - className="m-3" - /> - ) : ( -
- - - - - - - - - - - {visibleItems.map((item) => ( - - - - - - - ))} - -
设备原始值清洗值记录时间
- {item.device_id} - - {deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode} - - - {formatValue(item.monitored_value, item.measurement_unit)} - - {formatValue(item.cleaned_value, item.measurement_unit)} - - {item.observed_at ? ( - - ) : ( - 无记录 - )} -
-
- )} - - {snapshot && snapshot.total > snapshot.items.length ? ( -

- 当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。 -

- ) : null} -
- - } - tone="warning" - actions={canSimulate ? ( - - ) : null} - bodyClassName="space-y-3" - > - {!canSimulate ? ( - - 需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。 - - ) : null} - - {simulationEditorOpen && canSimulate ? ( -
-
- ({ - value: item.device_id, - label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}` - }))} - /> - -
- -