Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0801e161ec | ||
|
|
78481903d7 | ||
|
|
0ec74b15b2 | ||
|
|
74bf7c64c4 |
@@ -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 回答的展开阅读面板,以及这些界面的子弹窗和提示。
|
||||
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user