feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { withAnswerDisplayTimes } from "./answer-display-time";
describe("answer presentation time zone", () => {
it("formats report text, tables, evidence and axes while preserving IDs and source samples", () => {
const spec: Spec = { root: "root", elements: {
root: { type: "Result", props: { title: "诊断", summary: "目标2026-07-16T00:00:00Z,当前08:00(UTC+8)" }, children: [] },
table: { type: "Table", props: { title: "读数", columns: ["时间"], rows: [["2026-07-16T00:00:00Z"]] }, children: [] },
evidence: { type: "Evidence", props: { observed_at: "2026-07-16T00:00:00Z", evidence_id: "2026-07-16T00:00:00Z" }, children: [] },
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00 UTC"],
series: [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }] }, children: [] }
} };
const original = JSON.stringify(spec);
const result = withAnswerDisplayTimes(spec);
expect(result.elements.root.props.summary).toBe("目标2026/7/16 08:00:00(UTC+8),当前08:00(UTC+8)");
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
expect(result.elements.chart.props.x_axis_name).toBe("时间(UTC+8)");
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
expect(JSON.stringify(spec)).toBe(original);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
it("converts bare times when their table header or chart axis explicitly declares UTC", () => {
const spec: Spec = { root: "chart", elements: {
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00", "2026-12-31 17:00", "08:00+08:00"],
series: [{ name: "压力", data: [20, 21, 22] }] }, children: [] },
table: { type: "Table", props: { columns: ["观测时间(UTC)", "测点编号"],
rows: [["2026-12-31 17:00", "00:00"]] }, children: [] }
} };
const result = withAnswerDisplayTimes(spec);
expect(result.elements.chart.props.x_data).toEqual(["08:00", "2027/1/1 01:00:00", "08:00+08:00"]);
expect(result.elements.table.props.columns).toEqual(["观测时间(UTC+8)", "测点编号"]);
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
});
@@ -0,0 +1,37 @@
import type { Spec } from "@json-render/core";
import { formatDisplayText } from "@/shared/utils/date-time-text";
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
const timeAxis = (value: string) => formatDisplayText(value).replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8");
const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
function displayCell(value: string, heading: string): string {
return formatDisplayText(utcLabel.test(heading) && bareTime.test(value) ? `${value} UTC` : value);
}
/** Render display fields in UTC8 while keeping the stored spec and identifiers intact. */
export function withAnswerDisplayTimes(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => {
const props = { ...node.props };
for (const key of textFields) {
if (typeof props[key] === "string") props[key] = formatDisplayText(props[key]);
}
if (node.type === "Table") {
const columns = props.columns as string[];
props.rows = (props.rows as string[][]).map((row) => row.map((cell, index) => displayCell(cell, columns[index] ?? "")));
props.columns = columns.map(timeAxis);
}
if (node.type === "Chart") {
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
props.x_data = (props.x_data as string[]).map((label) => {
const displayed = displayCell(label, axis);
return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed;
});
if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name);
props.series = (props.series as Array<Record<string, unknown>>).map((series) => ({ ...series,
name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name }));
}
return [id, { ...node, props }];
})) };
}
@@ -11,5 +11,6 @@ export type AnswerUiInteractions = {
onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string;
submitMode?: "send" | "draft";
chartWindowKey?: string;
};
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -28,6 +28,7 @@ import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { withAnswerDisplayTimes } from "./answer-display-time";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
@@ -103,7 +104,7 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} />
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
</AnswerViewStateProvider>;
}
@@ -112,6 +113,7 @@ function AnswerUiBody({
disabled = false,
onLocate,
onSubmit,
chartWindowKey,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
@@ -130,7 +132,7 @@ function AnswerUiBody({
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
@@ -10,6 +10,10 @@ import {
const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
export function AnswerNodeStateScope({ id, children }: { id: string; children: ReactNode }) {
return <NodeContext.Provider value={id}>{children}</NodeContext.Provider>;
}
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
import { answerChartTableData, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
function spec(type: string, props: Record<string, unknown>): Spec {
return { root: "node", elements: { node: { type, props, children: [] } } };
@@ -10,6 +10,15 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"])
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => {
it("uses the same UTC8 sample display in chart tables while preserving stored samples", () => {
const series = [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }];
const current = spec("Chart", { chart_type: "line", x_data: ["08:00"],
series });
const state = normalizeAnswerViewState(current).nodes.node;
if (state.type !== "Chart") throw new Error("Expected chart state");
expect(answerChartTableData(current.elements.node, state).rows[0][1]).toBe("20 (2026-07-16 08:00:00+08:00)");
expect(series[0].sample_times[0]).toBe("2026-07-16T00:00:00Z");
});
it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
@@ -1,4 +1,5 @@
import type { Spec } from "@json-render/core";
import { chartSampleTime } from "./chart-presentation";
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
@@ -53,9 +54,12 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
}
export function answerChartTableData(node: Node, state: AnswerChartState) {
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
const visible = series.filter((_, index) => !state.hidden.includes(index));
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data, sample_times }) => {
const offset = state.range[0] + index, time = chartSampleTime(sample_times?.[offset]);
return `${data[offset] == null ? "缺测" : data[offset]}${time ? ` (${time})` : ""}`;
})]) };
}
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
+6 -1
View File
@@ -1,6 +1,7 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
import { createChartSampleTimeSchema } from "../presentation/command-schema.ts";
const shortText = z.string().max(200);
const text = z.string().max(4000);
@@ -64,11 +65,15 @@ export const answerUiCatalog = defineCatalog(schema, {
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(shortText).min(1).max(100),
series: z
.array(
z
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
.object({ name: shortText, type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema().nullable()).min(1).max(100).optional() })
.strict()
)
.min(1)
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
describe("monitoring chart comparison", () => {
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
expect(chartSampleTime("2026-12-31T16:00:00Z")).toBe("2027-01-01 00:00:00+08:00");
expect(chartSampleTime("2027-01-01T00:00:00+08:00")).toBe("2027-01-01 00:00:00+08:00");
expect(chartSampleTime(null)).toBe("");
});
it("frames current pressure and all historical points with a nonzero lower bound", () => {
const series = [{ name: "当前", data: [34, 32, 28] }, { name: "前一日", data: [null, 36, 37] }];
const bounds = chartValueBounds(series, 0, 2);
expect(bounds.min).toBeGreaterThan(0);
expect(bounds.min).toBeLessThan(28);
expect(bounds.max).toBeGreaterThan(37);
expect(chartValueBounds([{ name: "无数据", data: [null] }], 0, 0)).toEqual({});
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
});
it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
{ name: "缺测日", data: [null], sample_times: [null] }];
const tooltip = chartPointTooltip("12:00", series, 0);
expect(tooltip).toContain("当前: 28\n采样时间 2026-06-14 12:00:00+08:00");
expect(tooltip).toContain("前一日: 37\n采样时间 2026-06-13 12:00:00+08:00");
expect(tooltip).not.toContain("缺测日");
expect(chartValueLabel(null)).toBe("缺测");
expect(chartValueLabel(0)).toBe("0");
});
});
@@ -0,0 +1,28 @@
import type { SafeChartSeries } from "../chart-data";
import { formatDisplayTimestamp } from "@/shared/utils/date-time";
export function chartValueLabel(value: number | null | undefined): string {
return value == null ? "缺测" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(value);
}
export function chartSampleTime(time: string | null | undefined): string {
return time ? formatDisplayTimestamp(time) : "";
}
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) {
const values = series.flatMap((item) => item.data.slice(start, end + 1))
.filter((value): value is number => value !== null && Number.isFinite(value));
if (!values.length) return {};
const min = Math.min(...values), max = Math.max(...values);
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
return { min: min - padding, max: max + padding };
}
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
export function chartPointTooltip(label: string, series: SafeChartSeries[], index: number): string {
const rows = series.filter((item) => item.data[index] != null).map((item) => {
const time = chartSampleTime(item.sample_times?.[index]);
return `${item.name}: ${chartValueLabel(item.data[index])}${time ? `\n采样时间 ${time}` : ""}`;
});
return [label, ...rows].join("\n");
}
@@ -11,6 +11,19 @@ const block = {
}
};
describe("untrusted answer components", () => {
it("accepts a line with seven sparse history series while enforcing sample timestamp alignment", () => {
const x_data = Array.from({ length: 13 }, (_, index) => String(index));
const series = [{ name: "当前", type: "line", data: x_data.map(() => 31) },
...Array.from({ length: 7 }, (_, index) => ({ name: `前${index + 1}日`, type: "scatter",
data: [...Array(10).fill(null), 33, 34, 35],
sample_times: [...Array(10).fill(null), ...[30, 45, 59].map((minute) => `2026-06-${String(13 - index).padStart(2, "0")}T11:${minute}:00+08:00`)] }))];
const props = { title: "压力(m)", source: "合成样例", chart_type: "line", x_data, series };
const make = (value: unknown) => ({ ...block, spec: { root: "chart", elements: { chart: { type: "Chart", props: value, children: [] } } } });
expect(parseAnswerUiBlock(make(props))).not.toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [...series, series[0]] }))).toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: [null] }] }))).toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: Array(13).fill("2026-06-13T12:00:00+08:00") }] }))).toBeNull();
});
it("keeps explicit UI phases and rejects invalid phase values", () => {
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
+4 -2
View File
@@ -56,8 +56,10 @@ export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
if (!definition?.props.safeParse(node.props).success) return null;
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
if (node.type === "Chart") {
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array<string | null> }> };
if (data.series.some((series) => series.data.length !== data.x_data.length ||
series.sample_times && (series.sample_times.length !== data.x_data.length ||
series.sample_times.some((time, index) => time !== null && series.data[index] === null)))) return null;
}
if (node.type === "Table") {
const data = node.props as { columns: unknown[]; rows: unknown[][] };
+191 -105
View File
@@ -1,23 +1,29 @@
import { useLayoutEffect, useRef, useState } from "react";
import { memo, useContext, useLayoutEffect, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import ReactECharts from "echarts-for-react";
import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
export type AnswerChartProps = {
title: string;
chart_type: "line" | "bar";
x_data: string[];
series: Array<{ name: string; data: number[] }>;
series: SafeChartSeries[];
x_axis_name?: string;
y_axis_name?: string;
source: string;
};
import { useAnswerNodeState } from "./answer-view-state-provider";
import { AnswerNodeStateScope, AnswerViewStateProvider, useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerChartState } from "./answer-view-state";
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
@@ -29,12 +35,34 @@ function PointSelect({ label, value, labels, onChange }: {
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>;
}
export function InteractiveChart(props: AnswerChartProps) {
type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
const standaloneRegistry = {};
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */
export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresentation) {
const spec = { root: "chart", elements: { chart: { type: "Chart", props: {
title: props.title, chart_type: props.chart_type, x_data: props.x_data, series: props.series, source: props.source
}, children: [] } } };
return <AnswerViewStateProvider spec={spec} registry={standaloneRegistry}><AnswerNodeStateScope id="chart"><InteractiveChart {...props} /></AnswerNodeStateScope></AnswerViewStateProvider>;
}
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartWindowKey } = useContext(AnswerInteractionContext);
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const reducedMotion = useReducedMotion();
const pointer = useRef(false);
const plot = useRef<HTMLDivElement>(null);
const [animateInteraction, setAnimateInteraction] = useState(false);
const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => {
const element = plot.current;
if (props.expanded || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth);
resize();
const observer = new ResizeObserver(resize);
observer.observe(element);
return () => observer.disconnect();
}, [view, props.expanded]);
const contentMotion = useAnimationControls();
const pendingTransition = useRef(false);
useLayoutEffect(() => {
@@ -62,111 +90,65 @@ export function InteractiveChart(props: AnswerChartProps) {
const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => {
const values = series.data.slice(range[0], range[1] + 1);
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
const values = series.data.slice(range[0], range[1] + 1).filter((value): value is number => value !== null);
return { name: series.name, first: values[0], last: values.at(-1),
min: values.length ? Math.min(...values) : null, max: values.length ? Math.max(...values) : null };
});
const option = (type: "line" | "bar") => ({
animation: animateInteraction && !reducedMotion,
animationDuration: 180,
animationDurationUpdate: 180,
color: colors.filter((_, index) => !hidden.includes(index)),
aria: { enabled: true },
tooltip: { trigger: "axis", renderMode: "richText" },
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true },
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } },
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } },
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
});
return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.title}</CardTitle>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</CardHeader>
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none">
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)}
</div>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
<motion.div data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") {
patch({ point: range[0] + event.dataIndex, inspecting: true });
}
} }} />
</div>
</TabsContent>)}
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent>
</motion.div>
</Tabs>
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
const composite = props.series.some((series) => series.type === "scatter");
const categoryColors = visible.length === 1;
const horizontalBars = props.x_data.some((label) => label.length > 10);
const numberLabel = chartValueLabel;
const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars;
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) },
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
const valueAxis = { type: "value", scale: !includesBars,
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}),
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel },
splitLine: { lineStyle: { color: "#e2e8f0" } } };
return {
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true },
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
...(composite ? { formatter: (params: unknown) => {
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
const text = typeof first?.dataIndex === "number"
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : "";
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML.
const content = document.createElement("div");
content.textContent = text;
content.style.whiteSpace = "pre-line";
return content;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true },
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name,
nameLocation: "middle", nameGap: 30 },
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name },
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
};
};
const metadata = <>
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{item.first} → {item.last}</span>
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
@@ -183,13 +165,117 @@ export function InteractiveChart(props: AnswerChartProps) {
<dl className="my-2 space-y-1.5 text-xs">
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
<dd className="min-w-0 text-right font-medium tabular-nums">{numberLabel(series.data[pointIndex])}
{series.sample_times?.[pointIndex] ? <span className="block break-words text-[10px] font-normal text-muted-foreground">{chartSampleTime(series.sample_times[pointIndex])}</span> : null}
</dd>
</div>)}
</dl>
</motion.section> : null}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
</p>
</>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-[360px] min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
{() => <InteractiveChart {...props} expanded />}
</FloatingWindow> : null}
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(220px,1fr)_minmax(0,auto)] gap-x-4 gap-y-2 @min-[640px]:grid-cols-[minmax(0,1fr)_208px] @min-[640px]:grid-rows-[auto_auto_minmax(220px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-44 min-h-0 space-y-3 overflow-y-auto border-t border-border pt-3 @min-[640px]:col-start-2 @min-[640px]:row-start-1 @min-[640px]:row-span-3 @min-[640px]:max-h-none @min-[640px]:border-t-0 @min-[640px]:border-l @min-[640px]:pt-0 @min-[640px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[640px]:flex-col @min-[640px]:flex-nowrap @min-[640px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 max-w-full gap-1.5 whitespace-normal text-left text-[11px] ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded ? metadata : null}
</aside>
<div className={props.expanded ? "col-start-1 row-start-2" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
</div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? "h-full min-h-0 overflow-auto" : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div>
</div>
</TabsContent>)}
<TabsContent value="table" className={props.expanded ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => {
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
return `${series.data[offset] == null ? "缺测" : series.data[offset]}${time ? ` (${time})` : ""}`;
})])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent>
</motion.div>
{!props.expanded ? metadata : null}
</Tabs>
</CardContent>
</Card></Collapsible>;
}
});
@@ -2,16 +2,18 @@ import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange, expanded = false }: {
title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
expanded?: boolean;
}) {
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const state = controlledState ?? localState;
@@ -34,6 +36,9 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
{!expanded ? <FloatingWindow title={`${title} · 表格`} triggerLabel={`放大查看表格 ${title}`}>
{() => <InteractiveDataTable title={title} columns={columns} rows={rows} state={state} onStateChange={patch} expanded />}
</FloatingWindow> : null}
</div>
<div role="region" aria-label={title} tabIndex={0}
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
@@ -0,0 +1,37 @@
import { useLayoutEffect, useRef, type ComponentProps } from "react";
import ReactECharts from "echarts-for-react";
/** Resize the existing drawing at most once per frame without replacing its options. */
export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%";
onPointSelect: (index: number) => void;
}) {
const host = useRef<HTMLDivElement>(null);
const chart = useRef<ReactECharts>(null);
const ready = useRef(false);
useLayoutEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
});
});
observer.observe(element);
return () => { cancelAnimationFrame(frame); observer.disconnect(); };
}, []);
return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
style={{ height: "100%" }} onChartReady={(instance) => {
ready.current = true;
instance.resize({ width: "auto", height: "auto", animation: { duration: 0 } });
}}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") onPointSelect(event.dataIndex);
} }} />
</div>;
}
+10
View File
@@ -56,4 +56,14 @@ describe("chart data normalization", () => {
expect(data?.xData).toHaveLength(5);
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
});
it("preserves missing slots, real zero and scatter sample dates without shifting indices", () => {
const data = normalizeSafeChartData({
x_data: ["11:30", "11:45", "12:00", "12:15"],
series: [{ name: "前一日", type: "scatter", data: [null, "bad", 0, 31.5],
sample_times: ["2026-06-13T11:30:00+08:00", null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] }]
});
expect(data?.series[0]).toEqual({ name: "前一日", type: "scatter", data: [null, null, 0, 31.5],
sample_times: [null, null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] });
});
});
+17 -6
View File
@@ -1,3 +1,7 @@
import { createChartSampleTimeSchema } from "./presentation/command-schema";
const sampleTimeSchema = createChartSampleTimeSchema();
export type SafeChartSpec = {
title?: string;
chartType: "line" | "bar";
@@ -7,8 +11,9 @@ export type SafeChartSpec = {
export type SafeChartSeries = {
name: string;
data: number[];
type?: "line" | "bar";
data: Array<number | null>;
type?: "line" | "bar" | "scatter";
sample_times?: Array<string | null>;
};
export type SafeChartData = {
@@ -34,6 +39,7 @@ type RawChartSeries = {
points?: unknown;
values?: unknown;
type?: unknown;
sample_times?: unknown;
};
export function parseChartSpec(value: unknown): SafeChartSpec {
@@ -75,12 +81,12 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
.map((point, index) => {
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
if (!parsed) {
labelsFromPoints[index] = xData[index] ?? `${index + 1}`;
return null;
}
labelsFromPoints[index] = parsed.label;
return parsed.value;
})
.filter((item): item is number => item !== null);
});
if (!data.length) {
return null;
@@ -93,7 +99,11 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
return {
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
data,
type: item.type === "line" || item.type === "bar" ? item.type : undefined
type: item.type === "line" || item.type === "bar" || item.type === "scatter" ? item.type : undefined,
...(Array.isArray(item.sample_times) ? { sample_times: data.map((value, index) => {
const time = item.sample_times as unknown[];
return value !== null && sampleTimeSchema.safeParse(time[index]).success ? time[index] as string : null;
}) } : {})
};
})
.filter((item): item is SafeChartSeries => Boolean(item));
@@ -101,7 +111,8 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
const clippedXData = xData.slice(0, maxPoints);
const clippedSeries = normalizedSeries.map((series) => ({
...series,
data: series.data.slice(0, clippedXData.length)
data: series.data.slice(0, clippedXData.length),
...(series.sample_times ? { sample_times: series.sample_times.slice(0, clippedXData.length) } : {})
}));
return clippedXData.length > 0 && clippedSeries.length > 0
@@ -557,7 +557,6 @@ export function AgentCommandPanel({
{centeredComposer && !promptReference ? (
<ConversationEmptyState className="agent-chat-empty-state">
<h2 className="text-2xl font-medium tracking-tight text-foreground">有什么可以帮你?</h2>
<p className="text-xs leading-6 text-muted-foreground">询问供水问题,或从最近的任务继续。</p>
</ConversationEmptyState>
) : null}
<motion.div
@@ -1,9 +1,9 @@
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { memo, type ReactNode } from "react";
import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
import type { parseFrontendChart } from "../frontend-action/chart";
@@ -71,11 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
);
}
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
}
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) {
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
});
function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100);
@@ -83,173 +85,17 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
}
return (
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
<div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}>
{!embedded ? <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
</p>
<p className="mt-1 text-xs text-[var(--ink-muted)]">
{spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div> : null}
<div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
<span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
{embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
</span>
))}
</div>
</div>
<SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
</div>
);
}
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
const width = 360;
const height = 190;
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const values = data.series.flatMap((series) => series.data);
const min = Math.min(0, ...values);
const max = Math.max(...values);
const range = max - min || 1;
const xFor = (index: number) =>
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
const ticks = createTicks(min, max);
return (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
{ticks.map((tick) => {
const y = yFor(tick);
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
{data.xData.map((label, index) => (
<text
key={`${label}-${index}`}
x={xFor(index)}
y={height - 12}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
{shortLabel(label)}
</text>
))}
{data.series.slice(0, 4).map((series, seriesIndex) =>
(series.type ?? spec.chartType) === "bar" ? (
<BarSeries
key={series.name}
series={series}
seriesIndex={seriesIndex}
seriesCount={Math.min(data.series.length, 4)}
xFor={xFor}
yFor={yFor}
baselineY={yFor(0)}
/>
) : (
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
)
)}
</svg>
);
}
function LineSeries({
series,
seriesIndex,
xFor,
yFor
}: {
series: SafeChartSeries;
seriesIndex: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
}) {
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
const stroke = chartStroke(seriesIndex);
return (
<g>
<polyline
points={points}
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="agent-chart-line"
/>
{series.data.map((value, index) => (
<circle
key={`${series.name}-${index}`}
cx={xFor(index)}
cy={yFor(value)}
r="2.8"
fill={stroke}
className="agent-chart-point"
style={{ animationDelay: `${80 + index * 18}ms` }}
/>
))}
</g>
);
}
function BarSeries({
series,
seriesIndex,
seriesCount,
xFor,
yFor,
baselineY
}: {
series: SafeChartSeries;
seriesIndex: number;
seriesCount: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
baselineY: number;
}) {
const fill = chartStroke(seriesIndex);
const barWidth = Math.max(5, 22 / seriesCount);
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
return (
<g>
{series.data.map((value, index) => {
const y = yFor(value);
return (
<rect
key={`${series.name}-${index}`}
x={xFor(index) - barWidth / 2 + offset}
y={Math.min(y, baselineY)}
width={barWidth}
height={Math.max(1, Math.abs(baselineY - y))}
rx="2"
fill={fill}
className="agent-chart-bar"
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
/>
);
})}
</g>
);
return <StandaloneInteractiveChart
title={spec.title ?? "Agent 图表"}
chart_type={spec.chartType}
x_data={data.xData}
series={data.series.slice(0, 8)}
source="来源见关联依据。"
embedded={embedded}
expanded={expanded}
xAxisName={spec.xAxisName}
yAxisName={spec.yAxisName}
/>;
}
function RegisteredComponentEnvelope({
@@ -377,35 +223,6 @@ function getSurfaceLabel(surface: UIEnvelope["surface"]) {
return "地图叠加";
}
function createTicks(min: number, max: number) {
const range = max - min || 1;
return [max, min + range * 0.5, min];
}
function chartStroke(index: number) {
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
}
function chartColorClass(index: number) {
return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
}
function formatChartNumber(value: number) {
if (Math.abs(value) >= 100) {
return value.toFixed(0);
}
if (Math.abs(value) >= 10) {
return value.toFixed(1);
}
return value.toFixed(2);
}
function shortLabel(value: string) {
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -10,4 +10,14 @@ describe("frontend chart boundary", () => {
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
});
it("accepts sparse history with real timestamp metadata and rejects invented or misaligned timestamps", () => {
const series = { name: "前一天", type: "scatter", data: [null, 31],
sample_times: [null, "2026-06-13T12:00:00+08:00"] };
expect(parseFrontendChart({ ...chart, y_axis_name: "压力(m)", series: [chart.series[0], series] })).not.toBeNull();
for (const sample_times of [["2026-06-13T11:45:00+08:00", series.sample_times[1]],
[null], [null, "2026-06-13T12:00:00"], [null, "2026-02-30T12:00:00+08:00"]]) {
expect(parseFrontendChart({ ...chart, series: [{ ...series, sample_times }] })).toBeNull();
}
expect(parseFrontendChart({ ...chart, series: [{ ...series, type: "pie" }] })).toBeNull();
});
});
@@ -0,0 +1,42 @@
import { expect, it } from "vitest";
import { parseFrontendChart } from "../frontend-action/chart";
import { parseAnswerUiBlock } from "../answer-ui/contract";
import { normalizeSafeChartData } from "../chart-data";
// Keep this matrix in sync with Agent and Server chart timestamp conformance tests.
const cases: Array<[string, boolean]> = [
["2026-06-14T12:00:00Z", true], ["2026-06-14T12:00:00+08:00", true],
["2026-06-14T12:00:00-03:30", true], ["2024-02-29T23:59:59.123456Z", true],
["2026-06-14T12:00:00+23:59", true], [`2026-06-14T12:00:00.${"1".repeat(43)}Z`, true],
["2026-06-14 12:00:00+08:00", false], ["2026-W24-7T12:00:00+08:00", false],
["2026-06-14T12:00+08:00", false], ["2026-06-14T12:00:00+0800", false],
["2026-02-30T12:00:00Z", false], ["2026-13-14T12:00:00Z", false],
["2026-06-00T12:00:00Z", false], ["2026-02-29T12:00:00Z", false],
["2026-06-14T24:00:00Z", false], ["2026-06-14T12:00:60Z", false],
["2026-06-14T12:00:00+24:00", false], ["2026-06-14T12:00:00+08:60", false],
["2026-06-14T12:00:00", false], ["2026-06-14T12:00:00z", false],
["0000-06-14T12:00:00Z", false], [`2026-06-14T12:00:00.${"1".repeat(44)}Z`, false]
];
const chart = (data: Array<number | null>, sample_times: Array<string | null>) => ({
title: "压力", chart_type: "line", x_data: ["12:00"], series: [{ name: "测点", data, sample_times }]
});
const block = (value: ReturnType<typeof chart>) => ({
version: "answer-ui@1", block_id: "pressure", fallback_text: "压力数据", spec: { root: "chart", elements: {
chart: { type: "Chart", props: { ...value, source: "实测" }, children: [] }
} }
});
it.each(cases)("uses the same timestamp boundary for chart commands, answer blocks and legacy metadata: %s", (time, accepted) => {
const value = chart([1], [time]);
expect(parseFrontendChart(value) !== null).toBe(accepted);
expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted);
expect(normalizeSafeChartData(value)?.series[0].sample_times).toEqual([accepted ? time : null]);
});
it.each([
[[0], [null], true], [[null], [null], true], [[null], ["2026-06-14T12:00:00Z"], false], [[1], [], false]
] as Array<[Array<number | null>, Array<string | null>, boolean]>)("preserves zero, null and sample alignment: %j %j", (data, times, accepted) => {
const value = chart(data, times);
expect(parseFrontendChart(value) !== null).toBe(accepted);
expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted);
});
@@ -1,5 +1,12 @@
import { z } from "zod";
/** Sample timestamps retain their original spelling within the shared RFC3339 subset. */
export function createChartSampleTimeSchema(zod: typeof z = z) {
return zod.string().max(64).datetime({ offset: true }).regex(
/^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$/
);
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
const z = zod;
@@ -9,13 +16,17 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z
.array(
z
.object({
name: z.string().max(128),
data: z.array(z.number().finite()).min(1).max(100)
type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
})
.strict()
)
@@ -24,7 +35,9 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
})
.strict()
.refine(
(value) => value.series.every((series) => series.data.length === value.x_data.length),
(value) => value.series.every((series) => series.data.length === value.x_data.length &&
(!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data"
);
const position = z
@@ -0,0 +1,52 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { toast } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
vi.mock("sonner", () => ({ toast: { custom: vi.fn(), dismiss: vi.fn() } }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it.each<MapNoticeTone>(["info", "success", "warning", "error", "loading"])(
"keeps actionable %s notices until manual dismissal even with an explicit timeout", async (tone) => {
const onAction = vi.fn();
const id = showMapNotice({ tone, title: "报告已更新", message: "查看分析结果", duration: 10_000,
dismissible: false, actionLabel: "查看报告", onAction });
await Promise.resolve();
const [content, options] = vi.mocked(toast.custom).mock.calls[0];
expect(options).toMatchObject({ duration: Infinity, dismissible: true });
render(content(id));
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "查看报告" }));
expect(onAction).toHaveBeenCalledOnce();
expect(toast.dismiss).not.toHaveBeenCalled();
expect(options?.cancel).toMatchObject({ label: expect.anything(), onClick: expect.any(Function) });
}
);
it("preserves transient feedback and the explicit lifecycle of non-actionable notices", async () => {
showMapNotice({ tone: "success", message: "已保存" });
await Promise.resolve();
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 3200 });
showMapNotice({ tone: "warning", message: "稍后重试", duration: 8000, actionLabel: "缺少回调" });
await Promise.resolve();
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 8000 });
showMapNotice({ tone: "loading", message: "正在加载" });
await Promise.resolve();
const [content, options] = vi.mocked(toast.custom).mock.lastCall!;
expect(options).toMatchObject({ duration: Infinity, dismissible: false });
render(content("loading"));
expect(screen.queryByRole("button", { name: "关闭通知" })).not.toBeInTheDocument();
});
it("publishes after effects and cancels notices dismissed before publication", async () => {
showMapNotice({ id: "source", tone: "loading", message: "加载中" });
showMapNotice({ id: "source", tone: "success", message: "已完成" });
const cancelled = showMapNotice({ tone: "info", message: "已取消" });
dismissMapNotice(cancelled);
expect(toast.custom).not.toHaveBeenCalled();
await Promise.resolve();
expect(toast.custom).toHaveBeenCalledOnce();
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ id: "source", duration: 3200 });
});
@@ -1,46 +1,30 @@
import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner";
import { toast } from "sonner";
import { cn } from "@/shared/ui/cn";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
const pendingNotices = new Map<string | number, () => void>();
let nextNoticeId = 0;
export function showMapNotice(options: MapNoticeOptions) {
const id = options.id ?? `map-notice:${++nextNoticeId}`;
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
const hasAction = Boolean(options.actionLabel && options.onAction);
// Actionable notices must remain available even when a caller supplies a timeout.
const duration = hasAction
? Infinity
: options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = hasAction || (options.dismissible ?? tone !== "loading");
return toast.custom(
const publish = () => toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
id, duration, position, dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
? { label: <MapNoticeCloseIcon />, onClick: (event) => event.stopPropagation() }
: undefined,
classNames: {
cancelButton: cn(
@@ -48,11 +32,22 @@ export function showMapNotice(options: MapNoticeOptions) {
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
className: "w-full"
}
);
// Publish after React finishes effects: Sonner renews its subscription during
// those effects and can otherwise miss an alert while map sources update.
pendingNotices.set(id, publish);
queueMicrotask(() => {
if (pendingNotices.get(id) !== publish) return;
pendingNotices.delete(id);
publish();
});
return id;
}
export function dismissMapNotice(id?: string | number) {
if (id === undefined) pendingNotices.clear();
else pendingNotices.delete(id);
toast.dismiss(id);
}
@@ -1,4 +1,4 @@
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import { AlertTriangle, ArrowUpRight, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
@@ -32,16 +32,16 @@ export function MapNoticeContent({
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={
actionLabel && onAction ? (
<MapNoticeAction label={actionLabel} onClick={onAction} />
) : null
}
/>
<div data-slot="map-notice-summary" className="flex items-start gap-3 py-3.5 pl-4 pr-12">
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody title={title} message={message} />
</div>
{actionLabel && onAction ? (
<div data-slot="map-notice-actions" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-3 pl-14 pr-3">
<span className="text-xs leading-5 text-[var(--ink-muted)]">保留至手动关闭</span>
<MapNoticeAction label={actionLabel} onClick={onAction} />
</div>
) : null}
</MapNoticeFrame>
);
}
@@ -59,8 +59,11 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
// Sonner restores its saved focus on blur. Persistent action buttons must
// let the user move directly to another workbench control instead.
onFocus={(event) => event.stopPropagation()}
className={cn(
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
"glass-transient relative w-full overflow-hidden border text-sm text-slate-900",
MAP_READABLE_RADIUS_CLASS_NAME,
)}
>
@@ -90,23 +93,20 @@ function MapNoticeIcon({
function MapNoticeBody({
title,
message,
action
message
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
{title ? (
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
<span className="block break-words text-sm font-semibold leading-5 text-slate-950">{title}</span>
) : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
<span className={cn("block break-words text-sm leading-6 text-slate-600", title && "mt-1")}>
{message}
</span>
{action}
</span>
</div>
);
}
@@ -118,11 +118,12 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
size="sm"
variant="outline"
className={cn(
"pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
"pointer-events-auto h-9 max-w-full gap-1.5 whitespace-normal border-[var(--surface-border)] bg-[var(--action-soft)] px-2.5 text-xs text-[var(--action-blue-hover)]",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
{label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button>
);
}
@@ -6,6 +6,7 @@ export type MapNoticeOptions = {
title?: string;
message: string;
tone?: MapNoticeTone;
/** Actionable notices always remain until manually dismissed. */
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
+6 -1
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -8,10 +8,15 @@ export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notic
export function MapToaster() {
return (
<Toaster
className="map-notice-stack"
style={{ fontFamily: "inherit", "--width": "min(420px, calc(100vw - 24px))" } as CSSProperties}
containerAriaLabel="系统通知"
closeButton={false}
expand
visibleToasts={4}
position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
toastOptions={{
unstyled: true,
classNames: {
@@ -50,6 +50,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable()
}).strict();
const taskSummarySchema = z.object({
handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(),
task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
@@ -9,6 +9,77 @@ import {
} from "./project-client";
describe("workbench project client", () => {
it("explains project-list timeouts using the Server readiness result", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("读取授权项目超时(已等待 15 秒)");
expect(fetchMock).toHaveBeenCalledTimes(2);
const probe = fetchMock.mock.calls.find(([url]) => new URL(String(url)).pathname === "/ready");
expect(probe).toBeDefined();
expect(new Headers(probe?.[1]?.headers).has("Authorization")).toBe(false);
});
it("identifies metadata unavailability instead of exposing the raw abort message", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
}));
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("项目元数据库暂不可用");
});
it("names backend startup and an unavailable listener separately", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "not_ready", metadata_database: "not_checked", mcp_transport: "not_ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务正在启动");
fetchMock.mockRejectedValue(new DOMException("signal timed out", "TimeoutError"));
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务未响应");
});
it("preserves caller cancellation without probing readiness or presenting a timeout", async () => {
const controller = new AbortController();
controller.abort();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("retries a timed-out lookup after the backend recovers without keeping its cached failure", async () => {
let available = false;
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "ready", checks: { startup: "ready", metadata_database: "ready", mcp_transport: "ready" }
});
if (!available) throw new DOMException("signal timed out", "TimeoutError");
return Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1, limit: 1000, offset: 0 });
}));
const provider = async () => "token";
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("后端当前已就绪");
available = true;
await expect(findOwnedWorkbenchProject(provider)).resolves.toMatchObject({ project: { project_id: "supply" } });
});
it("keeps authorization failures distinct from service availability", async () => {
const fetchMock = vi.fn(async () => Response.json({ detail: "当前账号没有项目访问权限" }, { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("当前账号没有项目访问权限");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }));
+43 -36
View File
@@ -5,6 +5,7 @@ import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
import { PROJECT_REQUEST_TIMEOUT_MS, withProjectRequest } from "./project-request";
const projectSchema = z.object({
project_id: z.string().min(1),
@@ -117,11 +118,11 @@ export async function findOwnedWorkbenchProject(
if (existing && existing.expiresAt > Date.now()) {
return { project: await awaitProject(existing.promise, signal), accessToken };
}
const loadProject = async () => {
const loadProject = () => withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
// One caller cancelling must not cancel other consumers of this lookup.
signal: getAccessToken ? AbortSignal.timeout(15_000) : signal
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
@@ -131,7 +132,7 @@ export async function findOwnedWorkbenchProject(
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return project;
};
}, getAccessToken ? undefined : signal);
const pending = loadProject();
if (getAccessToken) {
projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
@@ -183,7 +184,7 @@ export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvide
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
let cache = projectListCaches.get(getAccessToken);
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS)),
PROJECT_CACHE_TTL_MS, { force });
if (!signal) return pending;
signal.throwIfAborted();
@@ -200,12 +201,14 @@ async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signa
const projects: WorkbenchProject[] = [];
let offset = 0;
while (true) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
headers: createWorkbenchHeaders(token), signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const page = projectPageSchema.parse(payload);
const page = await withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
headers: createWorkbenchHeaders(token), signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
return projectPageSchema.parse(payload);
}, signal);
projects.push(...page.items.filter((project) => project.status === "active"));
offset += page.items.length;
if (offset >= page.total) break;
@@ -236,19 +239,21 @@ async function fetchProjectMetadata(
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
}
const metadata = projectMetadataSchema.parse(payload);
if (metadata.project_id !== project.project_id) {
throw new Error("项目元数据与授权项目不一致");
}
return metadata;
return withProjectRequest(SERVER_API_BASE_URL, "读取项目配置", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
headers,
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
}
const metadata = projectMetadataSchema.parse(payload);
if (metadata.project_id !== project.project_id) {
throw new Error("项目元数据与授权项目不一致");
}
return metadata;
}, signal);
}
async function fetchAccessContext(
@@ -256,19 +261,21 @@ async function fetchAccessContext(
headers: Headers,
signal?: AbortSignal
) {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
headers,
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
}
const accessContext = accessContextSchema.parse(payload);
if (accessContext.project_id !== project.project_id) {
throw new Error("访问上下文与授权项目不一致");
}
return accessContext;
return withProjectRequest(SERVER_API_BASE_URL, "读取项目访问权限", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
headers,
signal: requestSignal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
}
const accessContext = accessContextSchema.parse(payload);
if (accessContext.project_id !== project.project_id) {
throw new Error("访问上下文与授权项目不一致");
}
return accessContext;
}, signal);
}
export function createWorkbenchHeaders(
@@ -0,0 +1,69 @@
import { z } from "zod";
export const PROJECT_REQUEST_TIMEOUT_MS = 15_000;
const READINESS_TIMEOUT_MS = 3_000;
const readinessSchema = z.object({
status: z.enum(["ready", "not_ready"]),
checks: z.object({
startup: z.string(),
metadata_database: z.string(),
mcp_transport: z.string()
})
});
const isTimeout = (error: unknown) => Boolean(error && typeof error === "object"
&& "name" in error && error.name === "TimeoutError");
async function describeServerReadiness(baseUrl: string): Promise<string> {
try {
const response = await fetch(`${baseUrl}/ready`, {
signal: AbortSignal.timeout(READINESS_TIMEOUT_MS),
headers: { Accept: "application/json" },
cache: "no-store"
});
const parsed = readinessSchema.safeParse(await response.json());
if (parsed.success) {
const { status, checks } = parsed.data;
if (checks.startup === "not_ready") {
return "后端服务正在启动,尚未准备好处理项目请求。请稍后重新加载。";
}
if (checks.metadata_database === "unavailable") {
return "后端项目元数据库暂不可用。请稍后重试,若持续失败,请检查后端数据库连接。";
}
if (checks.mcp_transport === "not_ready") {
return "后端查询服务尚未就绪。请稍后重新加载。";
}
if (response.ok && status === "ready") {
return "后端当前已就绪,请重新加载项目。";
}
}
return "暂时无法确认后端就绪状态。服务可能正在重启或响应缓慢,请稍后重新加载。";
} catch {
return "后端服务未响应,可能正在重启或热重载。请稍后重新加载,若持续失败,请检查后端服务。";
}
}
/** Keep caller cancellation distinct from a request deadline or unavailable backend. */
export async function withProjectRequest<T>(
baseUrl: string,
operation: string,
request: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal
): Promise<T> {
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) {
const diagnosis = await describeServerReadiness(baseUrl);
throw new Error(`${operation}超时(已等待 15 秒)。${diagnosis}`, { cause: error });
}
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
}
throw error;
}
}
@@ -21,6 +21,22 @@ const eventSchema = z.object({
detail: z.string(), task_id: z.string().nullable(), category: z.string().optional(),
change: z.enum(["raised", "resolved"]).optional(), resolved_at: z.string().optional()
});
const monitoringWeightsSchema = z.object({
burst_detection: z.number(), pressure: z.number(), outlet_flow: z.number()
});
const monitoringConfigSchema = z.object({
rule_version: z.string(), decision_policy: z.enum(["weighted", "all_signals"]),
pressure_device_ids: z.array(z.string()), outlet_flow_device_ids: z.array(z.string()),
weights: monitoringWeightsSchema, alert_threshold: z.number()
});
const calibrationSchema = z.object({
version: z.string().optional(), diagnostic_policy: z.string().optional(),
status: z.enum(["ready", "insufficient_data"]),
knowledge_cutoff: z.string(), history_start: z.string(),
pressure: z.record(z.string(), z.object({ threshold_percent: z.number() })),
outlet_flow: z.record(z.string(), z.object({ threshold_percent: z.number() })),
burst_detection: z.object({ threshold: z.number().optional() }).optional()
});
const monitoringSchema = z.object({
monitoring_id: z.string().uuid(),
period_start: z.string(), period_end: z.string(), knowledge_cutoff: z.string(),
@@ -29,7 +45,19 @@ const monitoringSchema = z.object({
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())
burst_detection: z.record(z.string(), z.unknown()),
anomaly_signals: z.array(z.object({
event_id: z.string(), rule_id: z.string(), device_ids: z.array(z.string()), detail: z.string()
})).optional(),
weighted: z.object({
score: z.number(), threshold: z.number(),
weights: monitoringWeightsSchema
}).passthrough().nullable().optional(),
incident: z.object({
incident_id: z.string().uuid(), status: z.enum(["active", "resolved"]),
action: z.enum(["raised", "ongoing", "resolved"]),
started_at: z.string(), last_observed_at: z.string(), recovery_count: z.number().int()
}).nullable().optional()
}).passthrough();
export const scadaReplaySchema = z.object({
replay_id: z.string().uuid(), project_id: z.string().uuid(), revision: z.number().int(),
@@ -39,6 +67,8 @@ export const scadaReplaySchema = z.object({
step_pending: z.boolean().default(false),
monitoring_count: z.number().int().default(0),
last_monitoring: monitoringSchema.nullable().default(null),
monitoring_config: monitoringConfigSchema.nullable().optional(),
monitoring_calibration: calibrationSchema.nullable().optional(),
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() })),
@@ -49,8 +79,11 @@ 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)
burst_monitoring_reason: z.string().nullable().default(null),
monitoring_config: monitoringConfigSchema.nullable().optional()
});
export type ScadaMonitoringConfig = z.infer<typeof monitoringConfigSchema>;
export type ScadaMonitoringCalibration = z.infer<typeof calibrationSchema>;
export type ScadaReplay = z.infer<typeof scadaReplaySchema>;
export type ScadaReplayRule = z.infer<typeof ruleSchema>;
export type ScadaReplayEvent = z.infer<typeof eventSchema>;
@@ -85,6 +118,12 @@ export class ScadaReplayClient {
request_id: crypto.randomUUID(), expected_revision: replay.revision, action
}));
}
async monitorAt(replay: ScadaReplay, atTime: string) {
return scadaReplaySchema.parse(await this.request(`/${replay.replay_id}/commands`, {
request_id: crypto.randomUUID(), expected_revision: replay.revision,
action: "monitor", at_time: atTime
}));
}
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; });
@@ -22,6 +22,7 @@ import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
import type {
OperationalTask,
@@ -542,7 +543,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
<DateTimeField label="预约时间" value={toLocalDateTime(draft.scheduled_at)} onChange={(value) => setDraft({ ...draft, scheduled_at: value ? new Date(value).toISOString() : null })} disabled={submitting !== null} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null}
{action.evidence_refs.length > 0 ? (
@@ -0,0 +1,42 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeatureLocationPanel } from "./feature-location-panel";
afterEach(cleanup);
const state = { pending: false, targets: [], targetResult: null, errorCode: null };
const createCommands = () => ({ highlight: vi.fn(), zoomToFeature: vi.fn(), locateAndHighlight: vi.fn(), clearHighlight: vi.fn() });
describe("FeatureLocationPanel", () => {
it("uses the selected feature's source and ID together", async () => {
const user = userEvent.setup();
const commands = createCommands();
const onUseSelectedFeature = vi.fn();
render(<FeatureLocationPanel commands={commands} controllerState={state}
detailFeature={{ id: "PRESSURE-17", layer: "scada", title: "压力监测点", subtitle: "", properties: {} }}
onUseSelectedFeature={onUseSelectedFeature} />);
await user.click(screen.getByRole("button", { name: "使用当前地图选中" }));
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("PRESSURE-17");
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
expect(commands.locateAndHighlight).toHaveBeenCalledWith({ sourceId: "scada", featureIds: ["PRESSURE-17"] });
expect(onUseSelectedFeature).toHaveBeenCalledOnce();
});
it("keeps map actions disabled until the map is ready and while a request is pending", async () => {
const user = userEvent.setup();
const commands = createCommands();
const props = { commands, controllerState: state, detailFeature: null };
const { rerender } = render(<FeatureLocationPanel {...props} enabled={false} />);
await user.type(screen.getByLabelText("要素 ID(可多个)"), "P-17");
const action = () => screen.getByRole("button", { name: "定位并高亮" });
expect(action()).toBeDisabled();
rerender(<FeatureLocationPanel {...props} enabled />);
expect(action()).toBeEnabled();
rerender(<FeatureLocationPanel {...props} controllerState={{ ...state, pending: true }} enabled />);
expect(screen.getByRole("button", { name: "正在定位要素" })).toBeDisabled();
expect(screen.getByRole("button", { name: "仅高亮" })).toBeDisabled();
expect(screen.getByRole("button", { name: "缩放到要素" })).toBeDisabled();
expect(commands.locateAndHighlight).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,102 @@
import { Crosshair, LocateFixed, MousePointer2, Trash2, ZoomIn } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/shared/ui/button";
import { FieldSelect } from "@/shared/ui/field-select";
import { Textarea } from "@/shared/ui/textarea";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, type AvailableWaterNetworkSourceId } from "../map/sources";
import type { FeatureBatchTarget, WorkbenchMapCommands, WorkbenchMapControllerState } from "../map/workbench-map-controller";
import type { DetailFeature } from "../types";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
type FeatureLocationDraft = {
sourceId: AvailableWaterNetworkSourceId;
featureId: string;
};
export type FeatureLocationPanelProps = {
commands: Pick<WorkbenchMapCommands, "highlight" | "zoomToFeature" | "locateAndHighlight" | "clearHighlight">;
controllerState: Pick<WorkbenchMapControllerState, "pending" | "targets" | "targetResult" | "errorCode">;
detailFeature: DetailFeature | null;
enabled?: boolean;
value?: FeatureLocationDraft;
onValueChange?: (value: FeatureLocationDraft) => void;
onUseSelectedFeature?: () => void;
showError?: boolean;
contentClassName?: string;
};
export function FeatureLocationPanel({ commands, controllerState, detailFeature, enabled = true,
value, onValueChange, onUseSelectedFeature, showError = true, contentClassName }: FeatureLocationPanelProps) {
const inputId = useId();
const helpId = useId();
const [draft, setDraft] = useState<FeatureLocationDraft>({ sourceId: "pipes", featureId: "" });
const { sourceId, featureId } = value ?? draft;
function updateDraft(next: FeatureLocationDraft) {
if (!value) setDraft(next);
onValueChange?.(next);
}
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = enabled && featureIds.length > 0 && !invalidIds && !controllerState.pending;
const canUseSelected = Boolean(detailFeature && AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId));
const target: FeatureBatchTarget = { sourceId, featureIds };
const result = controllerState.targetResult;
return (
<div className="space-y-3">
<FieldSelect label="业务源" value={sourceId} contentClassName={contentClassName}
onValueChange={(nextSourceId) => updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })}
options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label htmlFor={inputId} className="text-xs font-medium text-[var(--ink-muted)]">要素 ID(可多个)</label>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{featureIds.length} / {MAX_MAP_FEATURE_IDS}</span>
</div>
<Textarea id={inputId} value={featureId} rows={3} placeholder="粘贴要素 ID,支持多个"
onChange={(event) => updateDraft({ sourceId, featureId: event.target.value })} aria-describedby={helpId} aria-invalid={invalidIds}
className="min-h-24 resize-y border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
/>
<p id={helpId} className="text-[11px] leading-5 text-[var(--ink-muted)]">逗号、空格或换行分隔,自动去重。</p>
</div>
{invalidIds ? <p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
<Button type="button" variant="secondary" className="w-full text-xs" disabled={!canUseSelected}
onClick={() => {
if (!detailFeature || !canUseSelected) return;
updateDraft({ sourceId: detailFeature.layer as AvailableWaterNetworkSourceId, featureId: detailFeature.id });
onUseSelectedFeature?.();
}}>
<MousePointer2 aria-hidden="true" />使用当前地图选中
</Button>
<div className="space-y-2 border-t border-[var(--surface-divider)] pt-3">
<Button type="button" className="w-full text-xs" disabled={!canLocate}
loading={controllerState.pending} loadingLabel="正在定位要素"
onClick={() => void commands.locateAndHighlight(target)}>
<LocateFixed aria-hidden="true" />定位并高亮
</Button>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.highlight(target)}>
<Crosshair aria-hidden="true" />仅高亮
</Button>
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.zoomToFeature(target)}>
<ZoomIn aria-hidden="true" />缩放到要素
</Button>
</div>
</div>
{!enabled ? <p role="status" className="text-xs text-[var(--ink-muted)]">等待地图加载后即可定位</p> : null}
{showError && controllerState.errorCode && FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode] ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">{FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode]}</p>
) : null}
{result ? <p role="status" className="break-words text-xs leading-5 text-[var(--ink-muted)]">
本次命中 {result.featureIds.length} 个{result.missingIds.length ? `,未找到 ${result.missingIds.length} 个:${result.missingIds.join("、")}` : ",全部找到"}
</p> : null}
<div className="flex items-center justify-between gap-2 border-t border-[var(--surface-divider)] pt-2">
<p className="text-[11px] text-[var(--ink-muted)]">已高亮 {controllerState.targets.length} 个要素</p>
<Button type="button" variant="ghost" className="shrink-0 text-xs" onClick={() => commands.clearHighlight()}>
<Trash2 aria-hidden="true" />清除高亮
</Button>
</div>
</div>
);
}
@@ -137,7 +137,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
))}
</ul> : null}
{result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden", mapResult && "h-auto shrink-0")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className={mapResult ? "space-y-2" : "space-y-3"}>
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
@@ -1,3 +1,4 @@
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -18,8 +19,7 @@ describe("investigation Dev case selection", () => {
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onSample={(id = "combined") => createInvestigationDevCase(id)}
onPreview={onPreview}
onClear={vi.fn()}
/>
@@ -29,7 +29,7 @@ describe("investigation Dev case selection", () => {
await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01"
INVESTIGATION_DEV_NETWORK.pipeIds[0]
);
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
@@ -39,10 +39,10 @@ describe("investigation Dev case selection", () => {
expect.any(String)
);
expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table")),
"候选管段对比"
);
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
expect(screen.getByRole("status")).toHaveTextContent("本地预览完成");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比");
@@ -57,7 +57,6 @@ describe("investigation Dev case selection", () => {
mapReady={false}
charts={0}
overlay={null}
activity={[]}
onSample={vi.fn()}
onPreview={onPreview}
onClear={vi.fn()}
@@ -65,7 +64,7 @@ describe("investigation Dev case selection", () => {
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("data_only"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
@@ -75,7 +74,7 @@ describe("investigation Dev case selection", () => {
expect.any(String)
);
await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("impact_area"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled();
await user.click(execute);
@@ -93,15 +92,14 @@ describe("investigation Dev case selection", () => {
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId)}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
await user.paste(createInvestigationDevCase(id));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
@@ -111,7 +109,7 @@ describe("investigation Dev case selection", () => {
expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.paste(createInvestigationDevCase("data_only"));
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
@@ -10,12 +10,10 @@ import {
} from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationActionLabels,
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview,
type InvestigationPresentationActivity
type InvestigationDevPreview
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
@@ -23,7 +21,6 @@ export type InvestigationDevToolsProps = {
mapReady: boolean;
charts: number;
overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
@@ -34,7 +31,6 @@ export function InvestigationDevTools({
mapReady,
charts,
overlay,
activity,
onSample,
onPreview,
onClear
@@ -43,11 +39,9 @@ export function InvestigationDevTools({
const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false);
const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
@@ -56,10 +50,8 @@ export function InvestigationDevTools({
setInput(value);
setCaseId(nextCaseId);
setChecked(null);
setPreview(null);
setMessage(null);
setError(null);
setSelectedRecord(0);
}
function clearLoadedPreview() {
if (!previewActive.current) return;
@@ -100,7 +92,6 @@ export function InvestigationDevTools({
}
setRunning(true);
setError(null);
setPreview(parsed);
previewActive.current = true;
try {
await onPreview(parsed, selectedCase?.label ?? "JSONL");
@@ -174,7 +165,7 @@ export function InvestigationDevTools({
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
仅在当前浏览器预览,不写入任务或触发调查。空间位置与要素 ID 以 tjwater_v2 为基准;读数与方案为合成样例,不代表现场事实。
</p>
<FieldSelect
label="JSONL 测试案例"
@@ -197,7 +188,7 @@ export function InvestigationDevTools({
</p>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。
切换即载入,执行时自动校验;已执行的本地预览会清除。定位和高亮请使用“地图调试”页签的业务源与要素 ID。
</p>
<div className="grid grid-cols-2 gap-2">
<Button
@@ -296,7 +287,6 @@ export function InvestigationDevTools({
onClick={() => {
onClear();
previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。");
setError(null);
}}
@@ -319,92 +309,8 @@ export function InvestigationDevTools({
{error}
</p>
) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div>
) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div>
);
}
@@ -18,6 +18,7 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -84,6 +85,7 @@ export function InvestigationPresentationPanel({
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
const reviewing = history?.selectedId != null;
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
@@ -104,7 +106,7 @@ export function InvestigationPresentationPanel({
return (
<motion.aside
layout={immediate ? false : "size"}
layout={immediate || localPreview ? false : "size"}
{...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)}
@@ -112,18 +114,18 @@ export function InvestigationPresentationPanel({
data-testid="investigation-presentation-panel"
data-presentation-scope={scope}
data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg ${localPreview ? `${collapsed ? "h-28" : "h-[min(480px,55dvh)]"} [&>[data-investigation-content]]:flex-1` : ""}`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
>
<motion.header
layout={immediate ? false : "position"}
layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5"
className={`shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
<div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
<h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
<div className={`flex flex-wrap items-center gap-2 ${localPreview ? "col-span-2 row-start-2" : ""}`}>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing
? "历史回看"
@@ -147,18 +149,14 @@ export function InvestigationPresentationPanel({
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
className={`shrink-0 ${localPreview ? "col-start-2 row-start-1" : ""}`}
aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)}
>
<motion.span
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<motion.span className="inline-flex" animate={{ rotate: collapsed ? 180 : 0 }} transition={presentationTransition(immediate)}>
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
@@ -180,12 +178,12 @@ export function InvestigationPresentationPanel({
? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
{history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
<PresentationViewport
key={history?.selectedId ?? "current"}
key="presentation-content"
immediate={immediate}
id={contentId}
>
@@ -306,22 +304,31 @@ export function InvestigationPresentationPanel({
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={item.chart.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-[420px] min-w-0 flex-col gap-3">
<AgentChartView chart={item.chart} embedded expanded />
<div className="shrink-0">{references(item.evidence_refs)}</div>
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
@@ -335,6 +342,7 @@ export function InvestigationPresentationPanel({
blocks={blocks}
records={records}
reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
@@ -1,6 +1,7 @@
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import { formatInvestigationText } from "../utils/investigation-text";
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
task: Pick<OperationalTask, "evidence">;
@@ -56,7 +57,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
{block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{formatInvestigationText(item.block.fallback_text)}</p>}
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> : null}
@@ -1,11 +1,10 @@
import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
import { RotateCcw, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
LAYER_GROUP_GEOMETRIES,
@@ -15,18 +14,20 @@ import {
import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { getNumericFeatureProperties } from "../map/value-label";
import type {
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
import { FeatureLocationPanel } from "./feature-location-panel";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
import { NoticeDevTools } from "./notice-dev-tools";
type MapDevPanelProps = {
commands: WorkbenchMapCommands;
@@ -37,6 +38,11 @@ type MapDevPanelProps = {
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps;
onPreviewScadaAlert?: () => void;
onEndScadaAlertPreview?: () => void;
scadaAlertPreviewActive?: boolean;
scadaAlertIntensity?: ScadaAlertIntensity;
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
};
const GROUP_LABELS = Object.fromEntries(
@@ -93,13 +99,7 @@ const LABEL_PRESETS: Record<
};
const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
...FEATURE_LOCATION_ERROR_LABELS,
FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围"
@@ -113,13 +113,21 @@ export function MapDevPanel({
onClose,
investigationFollow = true,
onInvestigationFollowChange,
investigationTools
investigationTools,
onPreviewScadaAlert,
onEndScadaAlertPreview,
scadaAlertPreviewActive = false,
scadaAlertIntensity = "standard",
onScadaAlertIntensityChange
}: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
});
const { sourceId, featureId } = locationDraft;
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const [property, setProperty] = useState("diameter");
const [flowPipeId, setFlowPipeId] = useState("440681");
const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
const [flowVelocity, setFlowVelocity] = useState("-1");
const [precision, setPrecision] = useState(0);
const [unit, setUnit] = useState("mm");
@@ -148,10 +156,6 @@ export function MapDevPanel({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const target: FeatureBatchTarget = { sourceId, featureIds };
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
@@ -171,14 +175,6 @@ export function MapDevPanel({
function useSelectedFeature() {
if (!detailFeature) return;
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId;
@@ -246,7 +242,7 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
@@ -256,7 +252,7 @@ export function MapDevPanel({
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
调查联动验证与地图工具 · 仅当前页面生效
调查、通知与地图工具 · 仅当前页面生效
</p>
</div>
<Button
@@ -272,12 +268,27 @@ export function MapDevPanel({
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
<TabsTrigger value="notices">通知测试</TabsTrigger>
<TabsTrigger value="map">地图调试</TabsTrigger>
</TabsList>
</div>
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
</div> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
</div>
</section> : null}
{onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4">
<div className="space-y-2">
@@ -288,6 +299,9 @@ export function MapDevPanel({
</section>
) : null}
</TabsContent>
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
<NoticeDevTools />
</TabsContent>
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
@@ -297,100 +311,14 @@ export function MapDevPanel({
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
status={controllerState.pending ? "执行中" : undefined}
>
<div className="space-y-3">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
<FeatureLocationPanel commands={commands} controllerState={controllerState}
detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
onUseSelectedFeature={useSelectedFeature} showError={false}
contentClassName="workbench-shadcn-surface" />
</PanelSection>
<PanelSection
@@ -0,0 +1,28 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import { NoticeDevTools } from "./notice-dev-tools";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("previews every tone, completes loading in place and clears only its own notices", async () => {
render(<NoticeDevTools />);
const user = userEvent.setup();
for (const label of ["信息", "成功", "警告", "错误", "加载"]) {
await user.click(screen.getByRole("button", { name: `测试${label}通知` }));
}
expect(vi.mocked(showMapNotice).mock.calls.map(([options]) => options.tone)).toEqual(["info", "success", "warning", "error", "loading"]);
await user.click(screen.getByRole("button", { name: "模拟加载完成" }));
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "dev-notice:loading", tone: "success" }));
await user.click(screen.getByRole("button", { name: "测试报告通知" }));
const report = vi.mocked(showMapNotice).mock.lastCall![0];
expect(report.actionLabel).toBe("查看报告");
await user.click(screen.getByRole("button", { name: "连续 6 条通知" }));
expect(vi.mocked(showMapNotice).mock.calls.slice(-6).map(([options]) => options.id)).toEqual(
Array.from({ length: 6 }, (_, index) => `dev-notice:batch-${index}`)
);
await user.click(screen.getByRole("button", { name: "清除测试通知" }));
expect(vi.mocked(dismissMapNotice).mock.calls.every(([id]) => typeof id === "string" && id.startsWith("dev-notice:"))).toBe(true);
});
@@ -0,0 +1,87 @@
import { useState } from "react";
import { dismissMapNotice, showMapNotice, type MapNoticeOptions } from "@/features/map/core";
import { Button } from "@/shared/ui/button";
const samples = [
{ key: "info", label: "信息通知", tone: "info", title: "地图视图已更新", message: "已切换到当前项目的管网视图。", lifetime: "3.2 秒后关闭" },
{ key: "success", label: "成功通知", tone: "success", title: "操作已完成", message: "当前管网视图已导出,可以继续分析。", lifetime: "3.2 秒后关闭" },
{ key: "warning", label: "警告通知", tone: "warning", title: "部分监测数据延迟", message: "最新读数尚未到达,请稍后查看。", lifetime: "手动关闭" },
{ key: "error", label: "错误通知", tone: "error", title: "管网数据加载失败", message: "暂时无法连接数据源,请检查连接后重试。", lifetime: "手动关闭" },
{ key: "loading", label: "加载通知", tone: "loading", title: "正在加载管网", message: "正在准备图层和监测数据,请稍候。", lifetime: "等待完成" }
] satisfies (MapNoticeOptions & { key: string; label: string; lifetime: string })[];
const extraKeys = ["report", "task", "long", ...Array.from({ length: 6 }, (_, index) => `batch-${index}`)];
const noticeId = (key: string) => `dev-notice:${key}`;
export function NoticeDevTools() {
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(false);
function showLinked(key: string, target: "报告" | "任务", message?: string) {
showMapNotice({
id: noticeId(key),
tone: target === "报告" ? "success" : "warning",
title: `${target === "报告" ? "调查报告已更新" : "调查任务待处理"} · 测试`,
message: message ?? (target === "报告"
? "城南片区压力异常调查报告已更新,可查看分析结论与处置建议。"
: "新收到一项管网异常调查任务,请查看现场情况与待办事项。"),
actionLabel: `查看${target}`,
onAction: () => setFeedback(`已触发“查看${target}”测试操作,通知仍保留。`)
});
}
function clearSamples() {
[...samples.map((sample) => sample.key), ...extraKeys].forEach((key) => dismissMapNotice(noticeId(key)));
setLoading(false);
setFeedback("已清除测试通知。");
}
return (
<section aria-label="通知测试" className="space-y-5">
<p className="text-xs leading-5 text-muted-foreground">
本地样例,仅当前页面生效。报告和任务入口只记录点击反馈,不创建真实业务数据。
</p>
<div className="space-y-2">
<h3 className="text-xs font-semibold">通知类型</h3>
<div className="grid grid-cols-2 gap-2">
{samples.map(({ key, label, lifetime, ...options }) => (
<Button key={key} type="button" variant="outline" aria-label={`测试${label}`}
className="h-auto min-h-14 flex-col items-start gap-1 px-3 py-2 text-xs"
onClick={() => {
showMapNotice({ ...options, id: noticeId(key), title: `${options.title} · 测试` });
if (key === "loading") setLoading(true);
}}>
<span>{label}</span>
<span className="font-normal text-muted-foreground">{lifetime}</span>
</Button>
))}
</div>
<Button type="button" variant="ghost" size="sm" disabled={!loading}
onClick={() => {
showMapNotice({ id: noticeId("loading"), tone: "success", title: "管网加载完成 · 测试", message: "图层和监测数据已准备就绪。" });
setLoading(false);
}}>模拟加载完成</Button>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">报告与任务入口</h3>
<p className="text-xs leading-5 text-muted-foreground">带操作入口的通知保留至手动关闭,点击查看后继续保留。</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("report", "报告")}>测试报告通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("task", "任务")}>测试任务通知</Button>
</div>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">布局与堆叠</h3>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("long", "报告",
"城南供水片区夜间压力持续下降,多处监测点出现关联异常。调查报告已整理受影响管段、关键监测读数、候选原因和后续核查建议,请结合现场情况查看并安排处置。关联编号:SCADA-PRESSURE-20261107-REPORT-000128。")}>测试长文本通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => {
Array.from({ length: 6 }, (_, index) => showLinked(`batch-${index}`, index % 2 ? "任务" : "报告", `第 ${index + 1} 条测试通知。手动关闭当前通知后,可继续查看其余通知。`));
}}>连续 6 条通知</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={clearSamples}>清除测试通知</Button>
{feedback ? <p role="status" className="text-xs leading-5 text-muted-foreground">{feedback}</p> : null}
</section>
);
}
@@ -1,11 +1,13 @@
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
};
@@ -13,6 +15,7 @@ export function PresentationBusinessResults({
blocks,
records,
reviewing,
chartWindowKey,
onLocateTargets
}: Props) {
if (!blocks.length && !records.length) return null;
@@ -23,6 +26,7 @@ export function PresentationBusinessResults({
key={block.block_id}
block={block}
disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={
onLocateTargets && !reviewing
? (target) =>
@@ -38,40 +42,47 @@ export function PresentationBusinessResults({
))}
{!blocks.length && records.length ? (
<Card className="border-border shadow-none">
<CardHeader className="p-3 pb-2">
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 p-3 pb-2">
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
<FloatingWindow title="调查数据摘要" triggerLabel="放大查看表格 调查数据摘要">{() => <PresentationRecordTable records={records} />}</FloatingWindow>
</CardHeader>
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
<PresentationRecordTable records={records} />
</CardContent>
</Card>
) : null}
</section>
);
}
function PresentationRecordTable({ records }: { records: Array<Record<string, unknown>> }) {
return (
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -58,3 +58,24 @@ it("keeps the final historical result selectable when current has been cleared",
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
expect(onSelect).toHaveBeenCalledWith(history[1].id);
});
it("offers playback, speed, progress and keeps the latest snapshot pinned until returning to current", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
const playback = { playing: true, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
render(<PresentationHistorySwitcher entries={history} currentEntryId={history[1].id} selectedId={history[1].id} onSelect={onSelect} playback={playback} />);
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/2");
await user.click(screen.getByRole("button", { name: "暂停历史播放" }));
expect(playback.onToggle).toHaveBeenCalledTimes(1);
const progress = screen.getByRole("slider", { name: "历史播放进度" });
progress.focus();
await user.keyboard("{ArrowLeft}");
// jsdom does not implement the browser's native range keyboard default.
await user.click(screen.getByRole("combobox", { name: "历史播放速度" }));
await user.click(screen.getByRole("option", { name: /^2×$/ }));
expect(playback.onSpeedChange).toHaveBeenCalledWith(2);
await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null);
});
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -9,88 +9,116 @@ export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
onSelect
onSelect,
playback
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter(
(entry) => entry.status !== "running" && entry.id !== currentEntryId
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
);
const index =
selectedId === null
? available.length
: available.findIndex((entry) => entry.id === selectedId);
const playable = entries.filter((entry) => entry.status === "completed");
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
return (
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
<div className="flex min-w-0 items-center gap-1">
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<Radio size={12} aria-hidden />
回到当前
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId && entry.id !== selectedId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
>
<Radio size={12} aria-hidden />
回到当前
</Button>
) : null}
</div>
{playback ? (
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0"
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
disabled={!playable.length} onClick={playback.onToggle}>
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
</Button>
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress}
disabled={playable.length < 2}
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
</span>
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]">
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
@@ -14,7 +14,7 @@ export function ProjectStartupScreen({ state, error, onRetry }: {
<StartupScreen
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
description={error ? "项目加载已中断,具体原因如下。" : "正在准备你的项目工作台,完成后自动进入。"}
progressLabel="项目加载进度"
steps={[
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
@@ -0,0 +1,40 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { ScadaAlertAtmosphere } from "./scada-alert-atmosphere";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
const preview: ScadaAlertAtmosphereState = { id: "preview-1", projectId: "project-1", preview: true, alert: null };
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("shows a local preview with a dismiss button and no investigation action", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管告警效果预览");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
const close = screen.getByRole("button", { name: "关闭告警灯效" });
expect(close).toHaveTextContent("关闭灯效");
fireEvent.click(close);
expect(onDismiss).toHaveBeenCalledWith("preview-1");
expect(onOpen).not.toHaveBeenCalled();
});
it("opens the actual linked investigation while retaining the visual warning", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
const alert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常", description: "已启动调查", time: "08:00" };
render(<ScadaAlertAtmosphere atmosphere={{ ...preview, id: alert.id, preview: false, alert }} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管风险调查中");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onDismiss).not.toHaveBeenCalled();
expect(onOpen).toHaveBeenCalledWith(alert);
});
it("keeps the warning for a long-running event until its state is removed", () => {
vi.useFakeTimers();
const onDismiss = vi.fn(), onOpen = vi.fn();
const { rerender } = render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
act(() => vi.advanceTimersByTime(24 * 60 * 60_000));
expect(onDismiss).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toBeVisible();
rerender(<ScadaAlertAtmosphere atmosphere={null} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
@@ -0,0 +1,41 @@
import { createPortal } from "react-dom";
import { ArrowUpRight, TriangleAlert, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
import type { WorkbenchAlert } from "../types";
export type ScadaAlertIntensity = "soft" | "standard" | "strong";
/** Event-lifetime viewport warning with an independently dismissible visual layer. */
export function ScadaAlertAtmosphere({ atmosphere, onDismiss, onOpen, intensity = "standard" }: {
atmosphere: ScadaAlertAtmosphereState | null;
onDismiss: (id: string) => void;
onOpen: (alert: WorkbenchAlert) => void;
intensity?: ScadaAlertIntensity;
}) {
if (!atmosphere) return null;
const alert = atmosphere.alert;
return createPortal(
<div className="scada-alert-atmosphere status-tone-danger"
data-testid="scada-alert-atmosphere" data-alert-id={atmosphere.id} data-preview={atmosphere.preview} data-intensity={intensity}>
<div className="scada-alert-atmosphere-frame" aria-hidden="true" />
<div className="scada-alert-atmosphere-light" aria-hidden="true" />
<div role="status" aria-live="polite" className="scada-alert-atmosphere-notice glass-transient items-center gap-3 rounded-xl border p-3 text-foreground">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[var(--status-soft)] text-[var(--status-foreground)]">
<TriangleAlert size={19} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-[13px] leading-5 font-semibold">{atmosphere.preview ? "爆管告警效果预览"
: (atmosphere.count ?? 1) > 1 ? `${atmosphere.count} 起爆管风险事件处理中` : "爆管风险调查中"}</p>
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">{atmosphere.preview ? "持续预览 · 可在 Dev 面板模拟结束" : "持续至事件处理结束"}</p>
</div>
{alert?.conditionId ? <Button type="button" size="sm" variant="ghost" className="scada-alert-atmosphere-action h-9 shrink-0 gap-1 px-2 text-xs"
onClick={() => onOpen(alert)}>
查看调查<ArrowUpRight size={14} aria-hidden="true" />
</Button> : null}
<Button type="button" size="sm" variant="outline" className="scada-alert-atmosphere-close h-9 shrink-0 gap-1.5 px-2.5 text-xs" aria-label="关闭告警灯效" title="关闭当前视觉警告"
onClick={() => onDismiss(atmosphere.id)}><X size={14} aria-hidden="true" />关闭灯效</Button>
</div>
</div>, document.body
);
}
@@ -48,6 +48,8 @@ import {
import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel";
import { FeatureLocationPanel, type FeatureLocationPanelProps } from "./feature-location-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
@@ -98,6 +100,7 @@ const DRAW_TOOL_OPTIONS: Array<{
export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding;
featureLocation: FeatureLocationPanelProps;
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
@@ -140,6 +143,7 @@ export type ToolbarPanelProps = {
export function ToolbarPanel({
geocoding,
featureLocation,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -203,6 +207,7 @@ export function ToolbarPanel({
);
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const [locationMode, setLocationMode] = useState("address");
const panelVisible = Boolean(activeToolId);
useEffect(() => {
@@ -232,12 +237,28 @@ export function ToolbarPanel({
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
key="geocoding" title="定位" description={locationMode === "features" ? "按图层和要素 ID 定位" : geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
>
<GeocodingPanel geocoding={geocoding} />
<Tabs value={locationMode} onValueChange={(value) => {
if (value === "features" && geocoding.picking) geocoding.togglePicking();
setLocationMode(value);
}} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList aria-label="定位方式" className="surface-control grid shrink-0 grid-cols-2">
<TabsTrigger value="address">地址地名</TabsTrigger>
<TabsTrigger value="features">管网要素</TabsTrigger>
</TabsList>
<TabsContent value="address" forceMount hidden={locationMode !== "address"}
className="flex min-h-0 flex-1 flex-col overflow-hidden">
<GeocodingPanel geocoding={geocoding} />
</TabsContent>
<TabsContent value="features" forceMount hidden={locationMode !== "features"}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-0.5">
<FeatureLocationPanel {...featureLocation} />
</TabsContent>
</Tabs>
</MapControlPanel>
);
}
@@ -123,6 +123,7 @@ export function AlertMenu({
compact = false,
conditionFeedVisible,
onSelectAlert,
onRemoveAlert,
onToggleConditionFeed,
onAnalyzeAlertsWithAgent
}: {
@@ -133,6 +134,7 @@ export function AlertMenu({
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
@@ -200,30 +202,43 @@ export function AlertMenu({
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
return (
<DropdownMenuItem
<div
key={alert.id}
className={cn(
toneClassName,
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
className={cn("surface-well my-1 px-3 pb-3 pt-1", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2 text-xs text-[var(--ink-muted)]">
<StatusDot tone={tone} size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)] tabular-nums">{alert.time}</span>
<span className="font-mono tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</span>
</span>
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-[var(--surface-reading)] text-[var(--ink-muted)] transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
<div className="flex shrink-0 items-center gap-0.5">
<DropdownMenuItem
aria-label={`打开通知:${alert.title}`}
className={cn("h-10 justify-center gap-1 px-2 text-xs font-medium text-[var(--action-blue)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
onSelect={() => onSelectAlert(alert)}
>
打开
<ChevronRight size={14} aria-hidden="true" />
</DropdownMenuItem>
{onRemoveAlert ? (
<DropdownMenuItem
aria-label={`移除通知:${alert.title}`}
title="移除通知"
className={cn("h-10 w-10 justify-center p-0 text-[var(--ink-muted)] active:scale-95", MAP_COMPACT_RADIUS_CLASS_NAME, menuReadableRowClassName)}
onSelect={(event) => {
event.preventDefault();
onRemoveAlert(alert);
}}
>
<X size={14} aria-hidden="true" />
</DropdownMenuItem>
) : null}
</div>
</div>
<p className="break-words text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
<p className="mt-1 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
</div>
);
}) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
devPanelEnabled: boolean;
devPanelOpen: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
@@ -81,6 +82,7 @@ export function WorkbenchTopBar({
devPanelEnabled,
devPanelOpen,
onSelectAlert,
onRemoveAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
@@ -145,6 +147,7 @@ export function WorkbenchTopBar({
sourceLabel={getAlertSourceLabel(alertDataSource)}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onRemoveAlert={onRemoveAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
@@ -242,6 +245,7 @@ export function WorkbenchTopBar({
sourceLabel={getAlertSourceLabel(alertDataSource)}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onRemoveAlert={onRemoveAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
@@ -153,7 +153,7 @@ describe("live investigation presentation following", () => {
test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] });
oldDisplay.resolve();
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"]));
expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount);
expect(test.clear.mock.calls.length).toBe(clearCount);
expect(execute.mock.calls[1]?.[1].aborted).toBe(false);
} finally { oldDisplay.resolve(); test.follower.stop(); }
});
@@ -199,3 +199,51 @@ it("reads saved history even before the live event stream opens", async () => {
expect(test.execute).not.toHaveBeenCalled();
} finally { test.follower.stop(); }
});
it("keeps successful results across disconnect and failed reconnect, clearing only for a new identity", async () => {
let visible = "saved chart";
const clear = vi.fn(() => { visible = ""; });
const fetchCursor = vi.fn(async () => cursor(10));
const test = setup({ clear, fetchCursor });
test.follower.start(); test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
visible = "saved chart";
clear.mockClear();
test.channel.publish({ type: "disconnect" });
expect(visible).toBe("saved chart");
expect(clear).not.toHaveBeenCalled();
fetchCursor.mockRejectedValueOnce(new Error("offline"));
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("error", "offline"));
expect(visible).toBe("saved chart");
fetchCursor.mockResolvedValueOnce(cursor(20, 2));
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(clear).toHaveBeenCalledTimes(1));
expect(visible).toBe("");
} finally { test.follower.stop(); }
});
it("clears a changed generation before hydrating its saved results and ignores a stale connection", async () => {
const stale = deferred<InvestigationPresentationCursor>();
const order: string[] = [];
const fetchCursor = vi.fn(async () => cursor(10))
.mockResolvedValueOnce(cursor(10))
.mockImplementationOnce(() => stale.promise)
.mockResolvedValueOnce(cursor(30, 2));
const test = setup({ fetchCursor, clear: () => order.push("clear"), loadHistory: async (next) => { order.push(`history:${next.execution_generation}`); } });
test.follower.start();
try {
await vi.waitFor(() => expect(order).toContain("history:1"));
order.length = 0;
test.channel.publish({ type: "open" });
test.channel.publish({ type: "disconnect" });
test.channel.publish({ type: "open" });
await vi.waitFor(() => expect(order).toContain("history:2"));
expect(order).toEqual(["clear", "history:2"]);
stale.resolve(cursor(20));
await Promise.resolve();
expect(order).toEqual(["clear", "history:2"]);
} finally { stale.resolve(cursor(20)); test.follower.stop(); }
});
@@ -156,12 +156,16 @@ export class InvestigationPresentationFollower {
const cursor = await this.options.fetchCursor(controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
if (cursor.task_id !== this.options.taskId) throw new Error("调查游标与所选任务不一致");
if (this.identity && cursor.execution_generation < this.identity.execution_generation)
throw new Error("调查游标已过期,等待重新同步");
// Clear a changed identity before loading its replacement, never after hydration.
// Leave the baseline unbound until history is ready so live events stay buffered.
this.updateIdentity(cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.baseline = cursor.event_cursor;
this.lastSeen = cursor.event_cursor;
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.updateIdentity(cursor);
this.options.onStatus(
this.streamConnected && this.options.follow !== false ? "live" : "history"
);
@@ -180,12 +184,13 @@ export class InvestigationPresentationFollower {
this.binding = null;
this.work.abort();
this.work = new AbortController();
this.identity = null;
// Keep the last verified identity and successful view through transport failures.
// The owning hook clears task/project changes; updateIdentity handles new runs.
this.baseline = null;
this.lastSeen = 0;
this.lookupKey = null;
this.pending = [];
this.options.clear();
this.options.stopPulse();
this.options.onStatus("disconnected");
}
@@ -0,0 +1,43 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { useDismissedWorkbenchAlerts } from "./use-dismissed-workbench-alerts";
import type { WorkbenchAlert } from "../types";
const alert: WorkbenchAlert = { id: "alert-1", title: "压力异常", description: "等待核查", time: "08:00" };
beforeEach(() => localStorage.clear());
it("keeps removed alerts hidden after refresh while new notifications still appear", () => {
const { result, rerender, unmount } = renderHook(({ alerts }) => useDismissedWorkbenchAlerts(alerts, "project-1", "operator"),
{ initialProps: { alerts: [alert] } });
act(() => { expect(result.current.dismissAlert(alert)).toBe(true); });
expect(result.current.alerts).toEqual([]);
rerender({ alerts: [{ ...alert }, { ...alert, id: "alert-2" }] });
expect(result.current.alerts.map((item) => item.id)).toEqual(["alert-2"]);
unmount();
const restored = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
expect(restored.result.current.alerts).toEqual([]);
});
it("isolates users and projects and shows a new notification version", () => {
const item = { ...alert, notificationVersion: 1 };
const { result, rerender } = renderHook(({ project, user, version }) =>
useDismissedWorkbenchAlerts([{ ...item, notificationVersion: version }], project, user),
{ initialProps: { project: "project-1", user: "operator", version: 1 } });
act(() => { result.current.dismissAlert(item); });
rerender({ project: "project-2", user: "operator", version: 1 });
expect(result.current.alerts).toHaveLength(1);
rerender({ project: "project-1", user: "viewer", version: 1 });
expect(result.current.alerts).toHaveLength(1);
rerender({ project: "project-1", user: "operator", version: 1 });
expect(result.current.alerts).toEqual([]);
rerender({ project: "project-1", user: "operator", version: 2 });
expect(result.current.alerts).toHaveLength(1);
});
it("preserves the row when local storage cannot save the removal", () => {
const { result } = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage unavailable"); });
act(() => { expect(result.current.dismissAlert(alert)).toBe(false); });
expect(result.current.alerts).toEqual([alert]);
spy.mockRestore();
});
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import type { WorkbenchAlert } from "../types";
function readDismissed(key: string): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}
function alertKey(alert: WorkbenchAlert): string {
return JSON.stringify([alert.id, alert.notificationVersion ?? null]);
}
export function useDismissedWorkbenchAlerts(
alerts: WorkbenchAlert[], projectId: string | null, username: string
) {
const storageKey = `tjwater.dismissed-alerts@1:${JSON.stringify([projectId, username])}`;
const [stored, setStored] = useState(() => ({ key: storageKey, ids: readDismissed(storageKey) }));
// Switch scopes synchronously so another user's or project's rows never flash hidden.
if (stored.key !== storageKey) {
setStored({ key: storageKey, ids: readDismissed(storageKey) });
}
useEffect(() => {
const sync = (event: StorageEvent) => {
if (event.storageArea === localStorage && (event.key === storageKey || event.key === null)) {
setStored({ key: storageKey, ids: readDismissed(storageKey) });
}
};
window.addEventListener("storage", sync);
return () => window.removeEventListener("storage", sync);
}, [storageKey]);
const dismissAlert = useCallback((alert: WorkbenchAlert) => {
if (!projectId) return false;
const ids = [...new Set([...readDismissed(storageKey), alertKey(alert)])];
try {
localStorage.setItem(storageKey, JSON.stringify(ids));
} catch {
return false;
}
setStored({ key: storageKey, ids });
return true;
}, [projectId, storageKey]);
const dismissed = new Set(stored.key === storageKey ? stored.ids : readDismissed(storageKey));
return { alerts: alerts.filter((alert) => !dismissed.has(alertKey(alert))), dismissAlert };
}
@@ -0,0 +1,97 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { useInvestigationPresentation } from "./use-investigation-presentation";
const sync = { subscribePresentationEvents: vi.fn(() => () => undefined) } as unknown as WorkbenchSyncState;
const mapRef: RefObject<MapLibreMap | null> = { current: null };
const controller = { queryPresentationFeatures: vi.fn() } as unknown as WorkbenchMapController;
const padding = () => ({ top: 0, right: 0, bottom: 0, left: 0 });
const onClearLayers = vi.fn();
const options = { scope: "preview" as const, enabled: false, task: null, projectId: "one", sync, mapRef, mapReady: false, controller, getPadding: padding, onClearLayers };
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("restores the last successful result when an unfinished display is aborted", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => {
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
} }, abort.signal, "preview").catch(() => undefined);
});
await waitFor(() => expect(result.current.charts).toHaveLength(1));
await act(async () => { abort.abort(); await pending; });
expect(result.current.charts).toEqual([]);
expect(result.current.records).toEqual([{ value: "saved" }]);
expect(result.current.history.entries.at(-1)?.status).toBe("cancelled");
});
it("retries a cancelled live event in the same history row", async () => {
const { result } = renderHook(() => useInvestigationPresentation(options));
const command = { action: "open_chart" as const, chart_id: "retry", evidence_refs: ["test"], chart: {
title: "Recoverable", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "pressure", data: [20] }],
} };
const event = { task_id: "50000000-0000-4000-8000-000000000001", run_id: "55000000-0000-4000-8000-000000000001",
execution_generation: 1, event_seq: 1, command };
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => { pending = result.current.executeCommand(command, abort.signal, "live", event).catch(() => undefined); });
await waitFor(() => expect(result.current.charts).toHaveLength(1));
await act(async () => { abort.abort(); await pending; });
const id = result.current.history.entries[0].id;
act(() => result.current.history.onSelect(id));
await act(() => result.current.executeCommand(command, new AbortController().signal, "live", event));
expect(result.current.history.entries).toHaveLength(1);
expect(result.current.history.entries[0]).toMatchObject({ id, status: "completed" });
act(() => result.current.history.onSelect(null));
expect(result.current.charts[0].chart.title).toBe("Recoverable");
});
it("does not restore an old partial display after the project changes", async () => {
const { result, rerender } = renderHook(({ projectId }) => useInvestigationPresentation({ ...options, projectId }), { initialProps: { projectId: "one" } });
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old project" }], commands: [] }));
const abort = new AbortController();
let pending!: Promise<unknown>;
act(() => {
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
} }, abort.signal, "preview").catch(() => undefined);
});
await waitFor(() => expect(result.current.charts).toHaveLength(1));
rerender({ projectId: "two" });
await act(async () => { abort.abort(); await pending; });
expect(result.current.charts).toEqual([]);
expect(result.current.records).toEqual([]);
expect(result.current.history.entries).toEqual([]);
});
it("advances saved snapshots on its timer, ignores new results and stops when the user clears overlays", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useInvestigationPresentation(options));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 1 }], commands: [] }));
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 2 }], commands: [] }));
act(() => result.current.history.playback.onToggle());
expect(result.current.records).toEqual([{ value: 1 }]);
expect(result.current.history.playback.playing).toBe(true);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 3 }], commands: [] }));
expect(result.current.records).toEqual([{ value: 1 }]);
act(() => vi.advanceTimersByTime(1500));
expect(result.current.records).toEqual([{ value: 2 }]);
act(() => vi.advanceTimersByTime(1500));
expect(result.current.history.playback.playing).toBe(false);
expect(result.current.records).toEqual([{ value: 2 }]);
act(() => result.current.history.playback.onToggle());
expect(result.current.history.playback.playing).toBe(true);
act(() => result.current.clearMapOverlays());
expect(result.current.history.playback.playing).toBe(false);
expect(result.current.records).toEqual([{ value: 3 }]);
act(() => vi.advanceTimersByTime(3000));
expect(result.current.records).toEqual([{ value: 3 }]);
});
@@ -127,6 +127,7 @@ export function useInvestigationPresentation(options: Options) {
"connecting" | "live" | "history" | "disconnected" | "error"
>("disconnected");
const [error, setError] = useState<string | null>(null);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0);
const previewRef = useRef<AbortController | null>(null);
@@ -171,7 +172,7 @@ export function useInvestigationPresentation(options: Options) {
[publish, source]
);
const selectHistory = useCallback(
(id: number | null) => {
(id: number | null, pin = false) => {
const store = storeRef.current;
if (store.selectedId === null)
store.update({
@@ -179,7 +180,7 @@ export function useInvestigationPresentation(options: Options) {
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(id === store.currentEntryId ? null : id)) return;
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
@@ -228,8 +229,9 @@ export function useInvestigationPresentation(options: Options) {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
signal.throwIfAborted();
const store = storeRef.current;
const previousView = store.current;
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
if (eventKey && store.entries.some((entry) => entry.key === eventKey && entry.status === "completed"))
return { recorded: true, duplicate: true };
update((view) => ({
...view,
@@ -252,6 +254,7 @@ export function useInvestigationPresentation(options: Options) {
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
);
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current;
setActivity((current) => [
...current,
@@ -259,6 +262,7 @@ export function useInvestigationPresentation(options: Options) {
]);
let actionSnapshot = store.current;
const apply = (updater: (view: PresentationView) => PresentationView) => {
signal.throwIfAborted();
update(updater);
actionSnapshot = store.current;
};
@@ -285,7 +289,7 @@ export function useInvestigationPresentation(options: Options) {
})
);
signal.throwIfAborted();
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
@@ -300,13 +304,22 @@ export function useInvestigationPresentation(options: Options) {
viewing_history: true
};
} catch (cause) {
// An unfinished display may already have applied a chart or geometry before
// waiting for its frame. Roll back only its own view, never a newer scope.
if (signal.aborted && store.current === actionSnapshot) {
store.update(previousView);
actionSnapshot = previousView;
const map = valuesRef.current.mapRef.current;
if (map && store.selectedId === null && valuesRef.current.mapReady)
renderPresentationSnapshot(scene, map, previousView);
}
const message = signal.aborted
? "展示已取消"
: cause instanceof Error
? cause.message
: "展示失败";
if (historyId !== null)
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
@@ -339,6 +352,8 @@ export function useInvestigationPresentation(options: Options) {
loadHistory: async (cursor, signal) => {
const task = valuesRef.current.task;
if (!task || task.task_id !== taskId) return;
if (task.execution_generation !== cursor.execution_generation)
throw new Error("调查展示代次正在同步,请稍后重试");
const events = await fetchOperationalTaskPresentationHistory(
taskId,
cursor.event_cursor,
@@ -365,6 +380,9 @@ export function useInvestigationPresentation(options: Options) {
})
});
signal.throwIfAborted();
if (valuesRef.current.task?.task_id !== task.task_id ||
valuesRef.current.task.execution_generation !== task.execution_generation)
throw new Error("调查任务已切换,忽略旧展示结果");
storeRef.current.update(next);
publish();
const map = valuesRef.current.mapRef.current;
@@ -514,25 +532,26 @@ export function useInvestigationPresentation(options: Options) {
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
const map = valuesRef.current.mapRef.current;
if (!map) return "";
const task = valuesRef.current.task;
const progress =
task?.latest_progress?.execution_generation === task?.execution_generation
? task?.latest_progress
: null;
const target = progress?.progress.diagram?.node_refs.flatMap(
(node) => node.map_targets ?? []
)[0];
return createInvestigationDevCase(
caseId,
map.getCenter().toArray() as [number, number],
target
);
return createInvestigationDevCase(caseId);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
const store = storeRef.current;
store.advancePlayback();
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
}, 1500 / playbackSpeed);
return () => window.clearTimeout(timer);
}, [playing, selectedHistoryId, playbackSpeed, publish, scene]);
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
@@ -552,7 +571,27 @@ export function useInvestigationPresentation(options: Options) {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory
onSelect: selectHistory,
playback: {
playing,
speed: playbackSpeed,
onToggle: () => {
const store = storeRef.current;
if (store.playing) store.pausePlayback();
else {
if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
store.startPlayback(taskEntries);
}
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
onSpeedChange: (speed: number) => {
if ([0.5, 1, 2].includes(speed)) setPlaybackSpeed(speed);
},
onSeek: (id: number) => selectHistory(id, true)
}
},
executeCommand,
runPreview,
@@ -0,0 +1,97 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaAlertNotices } from "./use-scada-alert-notices";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
const alert: WorkbenchAlert = {
id: "incident-1", title: "SCADA 指标异常", description: "已启动调查,详细诊断见调查报告。",
time: "08:00", severity: "warning", conditionId: "operational-task:task-1",
source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 2 }
};
afterEach(() => { cleanup(); sessionStorage.clear(); vi.clearAllMocks(); });
it("restores old notifications quietly and announces each newly persisted SCADA incident once", () => {
const onOpen = vi.fn();
const { result, rerender } = renderHook(({ alerts }: { alerts: WorkbenchAlert[] | null }) =>
useScadaAlertNotices("project-1", alerts, onOpen), { initialProps: { alerts: null as WorkbenchAlert[] | null } });
rerender({ alerts: [alert] });
expect(showMapNotice).not.toHaveBeenCalled();
expect(result.current.atmosphere?.alert).toEqual(alert);
const next = { ...alert, id: "incident-2", conditionId: "operational-task:task-2" };
rerender({ alerts: [alert, next] });
rerender({ alerts: [alert, { ...next }] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
expect(result.current.atmosphere).toMatchObject({ preview: false, count: 2 });
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ alerts: [alert, { ...next }] });
expect(result.current.atmosphere).toBeNull();
expect(showMapNotice).toHaveBeenCalledWith(expect.objectContaining({
title: alert.title, tone: "warning", position: "top-center", actionLabel: "查看调查", duration: Infinity
}));
vi.mocked(showMapNotice).mock.calls[0][0].onAction?.();
expect(onOpen).toHaveBeenCalledWith(next);
expect(dismissMapNotice).not.toHaveBeenCalled();
});
it("retains linked notices after recovery and never reannounces the same incident", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [alert] });
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ alerts: [] });
expect(result.current.atmosphere).toBeNull();
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [alert] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
});
it("clears recovered data-quality notices without task links", () => {
const { rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
rerender({ alerts: [] });
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
});
it("clears popups when leaving a project and keeps a different project's initial alerts quiet", () => {
const { result, rerender, unmount } = renderHook(({ project, alerts }) =>
useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
rerender({ project: "project-1", alerts: [alert] });
rerender({ project: "project-2", alerts: [alert] });
expect(result.current.atmosphere?.projectId).toBe("project-2");
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
expect(showMapNotice).toHaveBeenCalledTimes(1);
rerender({ project: "project-2", alerts: [alert, { ...alert, id: "incident-2", severity: "critical" }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error" }));
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-2:incident-2");
});
it("ignores other sources and leaves data quality notices without a fabricated investigation link", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, source: undefined }] });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ actionLabel: undefined, onAction: undefined }));
expect(result.current.atmosphere).toBeNull();
rerender({ alerts: [{ ...alert, id: "quality-only", source: { ...alert.source!, kind: "scada_replay", replayId: "replay-1", ruleId: "quality-P1", batchSequence: 2 } }] });
expect(result.current.atmosphere).toBeNull();
});
it("previews locally and an older dismissal cannot clear a newer real incident", () => {
const { result, rerender } = renderHook(({ project, alerts }) => useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
act(() => result.current.previewAtmosphere());
const preview = result.current.atmosphere!;
expect(preview).toMatchObject({ preview: true, alert: null, projectId: "project-1" });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ project: "project-1", alerts: [alert] });
act(() => result.current.dismissAtmosphere(preview.id));
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ project: "project-2", alerts: [] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
export type { ScadaAlertAtmosphereState } from "./use-scada-event-atmosphere";
/** Announce newly persisted alerts, including while the peripheral panel is closed. */
export function useScadaAlertNotices(
projectId: string | null,
alerts: WorkbenchAlert[] | null,
onOpen: (alert: WorkbenchAlert) => void,
tasks: readonly ScadaEventTask[] | null = null
) {
const atmosphere = useScadaEventAtmosphere(projectId, alerts, tasks);
const openRef = useRef(onOpen);
openRef.current = onOpen;
const state = useRef<{ initialized: boolean; seen: Set<string>; visible: Set<string>; linked: Set<string> }>({
initialized: false, seen: new Set(), visible: new Set(), linked: new Set()
});
useEffect(() => {
const current = { initialized: false, seen: new Set<string>(), visible: new Set<string>(), linked: new Set<string>() };
state.current = current;
return () => { current.visible.forEach((id) => dismissMapNotice(id)); };
}, [projectId]);
useEffect(() => {
if (!alerts) return;
const current = state.current;
const incoming = alerts.filter((alert) => alert.source && "kind" in alert.source
&& alert.source.kind === "scada_replay");
const noticeId = (id: string) => `scada-alert:${projectId}:${id}`;
const activeIds = new Set(incoming.map((alert) => noticeId(alert.id)));
for (const id of current.visible) {
if (!activeIds.has(id) && !current.linked.has(id)) {
dismissMapNotice(id);
current.visible.delete(id);
}
}
for (const alert of incoming) {
if (current.seen.has(alert.id)) continue;
current.seen.add(alert.id);
// Opening a project restores its notification center without replaying old popups.
if (!current.initialized) continue;
const id = noticeId(alert.id);
current.visible.add(id);
if (alert.conditionId) current.linked.add(id);
showMapNotice({
id,
title: alert.title,
message: alert.description,
tone: alert.severity === "critical" ? "error" : "warning",
position: "top-center",
duration: alert.conditionId ? Infinity : 10_000,
actionLabel: alert.conditionId ? "查看调查" : undefined,
onAction: alert.conditionId ? () => openRef.current(alert) : undefined
});
}
current.initialized = true;
}, [alerts, projectId]);
return atmosphere;
}
@@ -0,0 +1,63 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
import type { WorkbenchAlert } from "../types";
const task: ScadaEventTask = {
task_id: "task-1", project_id: "project-1", task_type: "event_investigation", workflow: "scada_anomaly",
lifecycle_status: "open", title: "监测事件", created_at: "2026-07-16T00:00:00Z",
source: { kind: "external_system", external_id: "scada:1", external_revision: 1, event_kind: "scada_anomaly",
replay_context: { replay_id: "replay-1", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 1 } }
};
const alert: WorkbenchAlert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常",
time: "08:00", description: "调查已启动", source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 1 } };
afterEach(() => { cleanup(); sessionStorage.clear(); });
it("retains an event after sensor-alert recovery, human waiting and execution failure, until event completion", () => {
const { result, rerender } = renderHook(({ alerts, tasks }) => useScadaEventAtmosphere("project-1", alerts, tasks),
{ initialProps: { alerts: [alert], tasks: [task] } });
expect(result.current.atmosphere?.count).toBe(1);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "waiting_human" }] });
expect(result.current.atmosphere?.alert?.conditionId).toBe(alert.conditionId);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "failed" }] });
expect(result.current.atmosphere).not.toBeNull();
rerender({ alerts: [alert], tasks: [{ ...task, lifecycle_status: "completed" }] });
expect(result.current.atmosphere).toBeNull();
});
it("restores unfinished events on reload and remembers a manual dismissal for that event", () => {
const first = renderHook(() => useScadaEventAtmosphere("project-1", null, [task]));
expect(first.result.current.atmosphere).not.toBeNull();
act(() => first.result.current.dismissAtmosphere(first.result.current.atmosphere!.id));
expect(first.result.current.atmosphere).toBeNull();
first.unmount();
const next = renderHook(() => useScadaEventAtmosphere("project-1", [alert], [task]));
expect(next.result.current.atmosphere).toBeNull();
});
it("keeps other events visible when one ends and shows a new event after the earlier group was dismissed", () => {
const second = { ...task, task_id: "task-2" };
const { result, rerender } = renderHook(({ tasks }) => useScadaEventAtmosphere("project-1", [], tasks),
{ initialProps: { tasks: [task, second] } });
expect(result.current.atmosphere?.count).toBe(2);
rerender({ tasks: [{ ...task, lifecycle_status: "cancelled" }, second] });
expect(result.current.atmosphere?.count).toBe(1);
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ tasks: [second, { ...task, task_id: "task-3" }] });
expect(result.current.atmosphere?.count).toBe(1);
expect(result.current.atmosphere?.alert?.conditionId).toBe("operational-task:task-3");
});
it("isolates project state and lets Dev end only the local preview", () => {
const { result, rerender } = renderHook(({ project, tasks }) => useScadaEventAtmosphere(project, [], tasks),
{ initialProps: { project: "project-1", tasks: [] as ScadaEventTask[] } });
act(() => result.current.previewAtmosphere());
expect(result.current.atmosphere?.preview).toBe(true);
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere).toBeNull();
rerender({ project: "project-1", tasks: [task] });
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere?.preview).toBe(false);
rerender({ project: "project-2", tasks: [task] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { OperationalTaskSummary } from "../api/operational-task-client";
import type { WorkbenchAlert } from "../types";
export type ScadaEventTask = Pick<OperationalTaskSummary,
"task_id" | "project_id" | "task_type" | "workflow" | "lifecycle_status" | "source" | "title" | "created_at">;
export type ScadaAlertAtmosphereState = {
id: string;
projectId: string | null;
alert: WorkbenchAlert | null;
preview: boolean;
count?: number;
};
const storageKey = (project: string | null) => `tjwater.scada-visual-dismissed@1:${project}`;
function readDismissed(project: string | null): string[] {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(storageKey(project)) ?? "[]");
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
} catch { return []; }
}
/** Visual state follows event completion, independently of sensor recovery or run outcomes. */
export function useScadaEventAtmosphere(projectId: string | null, alerts: WorkbenchAlert[] | null,
tasks: readonly ScadaEventTask[] | null) {
const storedDismissed = useMemo(() => readDismissed(projectId), [projectId]);
const [dismissed, setDismissed] = useState<{ project: string | null; ids: string[] } | null>(null);
const muted = dismissed?.project === projectId ? dismissed.ids : storedDismissed;
const [preview, setPreview] = useState<ScadaAlertAtmosphereState | null>(null);
useEffect(() => setPreview(null), [projectId]);
const candidates = new Map<string, WorkbenchAlert>();
const currentTasks = tasks?.filter((task) => task.project_id === projectId) ?? [];
const ended = new Set(currentTasks.filter((task) => ["completed", "cancelled"].includes(task.lifecycle_status))
.map((task) => `operational-task:${task.task_id}`));
for (const alert of alerts ?? []) {
if (alert.source && "kind" in alert.source && alert.source.kind === "scada_replay"
&& alert.source.ruleId === "scada_diagnosis" && alert.conditionId && !ended.has(alert.conditionId)) {
candidates.set(alert.conditionId, alert);
}
}
for (const task of currentTasks) {
const id = `operational-task:${task.task_id}`;
if (task.task_type !== "event_investigation" || !task.source.replay_context
|| !["scada_anomaly", "burst_report"].includes(task.workflow) || ended.has(id) || candidates.has(id)) continue;
candidates.set(id, { id, conditionId: id, title: task.title,
description: "监测事件持续处理中", occurredAt: task.created_at, time: "" });
}
const ids = [...candidates.keys()].filter((id) => !muted.includes(id)).sort();
const realKey = ids.join("|");
useEffect(() => { if (realKey) setPreview(null); }, [realKey]);
const atmosphere: ScadaAlertAtmosphereState | null = ids.length ? {
id: realKey, projectId, alert: candidates.get(ids[0])!, preview: false, count: ids.length
} : preview?.projectId === projectId ? preview : null;
const previewAtmosphere = useCallback(() => {
if (realKey) return;
setPreview({ id: `preview:${crypto.randomUUID()}`, projectId, alert: null, preview: true });
}, [projectId, realKey]);
const endAtmospherePreview = useCallback(() => setPreview(null), []);
const dismissAtmosphere = (id: string) => {
if (id !== atmosphere?.id) return;
setPreview(null);
if (atmosphere.preview) return;
const next = [...new Set([...muted, ...ids])];
setDismissed({ project: projectId, ids: next });
try { sessionStorage.setItem(storageKey(projectId), JSON.stringify(next)); } catch { /* Retain the current page preference. */ }
};
return { atmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere };
}
+56 -9
View File
@@ -116,6 +116,9 @@ import type {
} from "./types";
import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts";
import { getOperationalNotificationSeverity } from "./utils/operational-notification-routing";
import { useScadaAlertNotices } from "./hooks/use-scada-alert-notices";
import { useDismissedWorkbenchAlerts } from "./hooks/use-dismissed-workbench-alerts";
import { ScadaAlertAtmosphere, type ScadaAlertIntensity } from "./components/scada-alert-atmosphere";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
@@ -158,6 +161,7 @@ function MapWorkbenchContent({
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [analysisResultsPanelOpen, setAnalysisResultsPanelOpen] = useState(false);
const [peripheralSamplePanelOpen, setPeripheralSamplePanelOpen] = useState(false);
const [scadaAlertIntensity, setScadaAlertIntensity] = useState<ScadaAlertIntensity>("standard");
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
@@ -225,11 +229,12 @@ function MapWorkbenchContent({
|| condition.operationalTask?.task_type === "external_work_order"
)
], [operationalTasks.conditions, runtimeScheduledConditions]);
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const allWorkbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
const severity = getOperationalNotificationSeverity(notification.kind);
return severity ? [{
id: `operational-task-notification:${notification.notification_id}`,
notificationVersion: notification.version,
conditionId: operationalTaskConditionId(notification.task_id),
title: notification.title,
description: notification.description,
@@ -240,6 +245,20 @@ function MapWorkbenchContent({
});
return [...taskNotifications, ...runtimeWorkbenchAlerts];
}, [operationalTasks.notifications, runtimeWorkbenchAlerts]);
const { alerts: workbenchAlerts, dismissAlert } = useDismissedWorkbenchAlerts(
allWorkbenchAlerts, projectPresentation.projectId, projectPresentation.user.name
);
const handleRemoveAlert = (alert: WorkbenchAlert) => {
if (!dismissAlert(alert)) {
showMapNotice({ tone: "warning", title: "通知未移除", message: "无法保存移除记录,请确认项目已加载且浏览器允许本地存储后重试。" });
}
};
const { atmosphere: scadaAlertAtmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere } = useScadaAlertNotices(
projectPresentation.projectId,
workbenchSync.snapshot?.runtime.alertAvailability === "ready" ? runtimeWorkbenchAlerts : null,
handleSelectAlert,
operationalTasks.loading ? workbenchSync.snapshot?.tasks ?? null : operationalTasks.tasks
);
const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => {
@@ -550,6 +569,7 @@ function MapWorkbenchContent({
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!readingWorkspace?.active &&
(isLargeScreen || activeToolId !== "geocoding") &&
!artifactActive &&
mobileSheet === null &&
!conditionFeedExpanded &&
@@ -853,6 +873,7 @@ function MapWorkbenchContent({
return;
}
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setConditionFocusRequest((current) => ({
conditionId,
@@ -1064,6 +1085,7 @@ function MapWorkbenchContent({
const toolbarPanelProps: ToolbarPanelProps = {
geocoding,
featureLocation: { commands: mapController, controllerState: mapControllerState, detailFeature, enabled: mapReady },
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -1257,9 +1279,12 @@ function MapWorkbenchContent({
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
};
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active);
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
return (
<main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
className="relative isolate h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
data-basemap-tone={basemapTone}
style={
{
@@ -1269,6 +1294,7 @@ function MapWorkbenchContent({
} as CSSProperties
}
>
<ScadaAlertAtmosphere atmosphere={scadaAlertAtmosphere} onDismiss={dismissAtmosphere} onOpen={handleSelectAlert} intensity={scadaAlertIntensity} />
<WorkbenchTopBar
dataTime={displayedDataTime}
modelName={
@@ -1281,7 +1307,7 @@ function MapWorkbenchContent({
alertDataSource={alertDataSource}
user={projectPresentation.user}
projectSelection={projectSelection}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
conditionFeedVisible={!conditionFeedSuspended && (isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition")}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
@@ -1290,6 +1316,7 @@ function MapWorkbenchContent({
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectAlert={handleSelectAlert}
onRemoveAlert={handleRemoveAlert}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
@@ -1336,7 +1363,17 @@ function MapWorkbenchContent({
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
mapReady={mapReady}
mapRef={mapRef}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
onOpenInvestigation={(taskId) => {
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
const conditionId = operationalTaskConditionId(taskId);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 }));
operationalTasks.refresh();
}}
/>
<WorkbenchAgentPanels
@@ -1470,7 +1507,7 @@ function MapWorkbenchContent({
</WorkbenchAgentPanels>
{conditionFeedMounted ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen || readingWorkspace?.active ? { display: "none" } : undefined}>
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}>
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
@@ -1516,10 +1553,15 @@ function MapWorkbenchContent({
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
onPreviewScadaAlert={previewAtmosphere}
onEndScadaAlertPreview={endAtmospherePreview}
scadaAlertPreviewActive={scadaAlertAtmosphere?.preview === true}
scadaAlertIntensity={scadaAlertIntensity}
onScadaAlertIntensityChange={setScadaAlertIntensity}
investigationTools={{
task: selectedInvestigationTask, mapReady,
charts: previewPresentation.charts.length, overlay: previewPresentation.overlay?.title ?? null,
activity: [...presentation.activity, ...previewPresentation.activity], onSample: previewPresentation.createSample,
onSample: previewPresentation.createSample,
onPreview: previewPresentation.runPreview, onClear: previewPresentation.clearPreview
}}
onClose={() => setDevPanelOpen(false)}
@@ -1529,11 +1571,16 @@ function MapWorkbenchContent({
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
style={{
bottom: mobileOverlayLayout.mapControlBottom,
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
} as CSSProperties}
>
<ToolbarPanel
{...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
panelWidthClassName={activeToolId === "geocoding"
? "w-full max-h-[var(--mobile-tool-available-height)]"
: "w-full max-h-[52dvh] overflow-y-auto"}
/>
</div>
) : null}
@@ -1602,8 +1649,8 @@ function MapWorkbenchContent({
{mobileSheet ? (
<MobileWorkbenchSheet
className={readingWorkspace?.active ? "hidden" : undefined}
suspended={Boolean(readingWorkspace?.active)}
className={mobileSheetSuspended ? "hidden" : undefined}
suspended={mobileSheetSuspended}
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
@@ -12,8 +12,8 @@ type OverlayOpacity = "fill-opacity" | "circle-opacity" | "circle-stroke-opacity
type OverlayOpacityValues = Partial<Record<OverlayOpacity, number>>;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.state.risk, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.state.risk, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-point`, source: INVESTIGATION_OVERLAY_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": MAP_STYLE_TOKENS.supply.waterQuality, "circle-radius": 6, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-heatmap`, source: INVESTIGATION_OVERLAY_SOURCE, type: "heatmap", paint: {
@@ -0,0 +1,9 @@
export const FEATURE_LOCATION_ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "要素查询暂不可用,请稍后重试",
SCADA_API_UNAVAILABLE: "监测点查询暂不可用,请稍后重试",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "地图定位已被新请求替代"
};
+2 -2
View File
@@ -4,8 +4,8 @@ import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "geocoding",
label: "地址定位",
description: "查询地址并定位到地图",
label: "定位",
description: "查询地址或管网要素",
icon: Search
},
{
@@ -0,0 +1,12 @@
export type EventGpsCoordinate = [longitude: number, latitude: number];
export function formatGpsCoordinate([longitude, latitude]: EventGpsCoordinate) {
return `${longitude.toFixed(6)}, ${latitude.toFixed(6)}`;
}
export function mergeEventLocation(location: string, coordinate: EventGpsCoordinate | null) {
const text = location.trim();
if (!coordinate) return text;
const gps = `GPS:${formatGpsCoordinate(coordinate)}`;
return text ? `${text}(${gps})` : gps;
}
@@ -0,0 +1,56 @@
import { Marker, type Map as MapLibreMap } from "maplibre-gl";
import type { EventGpsCoordinate } from "./event-location-coordinate";
const arrowDirections: Record<string, [number, number]> = {
ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]
};
export function createEventLocationMarker({ map, element, coordinate, disabled, onChange, onDragChange }: {
map: MapLibreMap;
element: HTMLButtonElement;
coordinate: EventGpsCoordinate;
disabled: boolean;
onChange: (coordinate: EventGpsCoordinate) => void;
onDragChange: (dragging: boolean) => void;
}) {
const marker = new Marker({ element, anchor: "bottom", draggable: !disabled })
.setLngLat(coordinate)
.addTo(map);
element.setAttribute("aria-label", "事件位置标记,可拖拽调整");
element.setAttribute("title", "拖拽调整事件位置,方向键微调,Shift 加速");
element.setAttribute("aria-keyshortcuts", "ArrowUp ArrowDown ArrowLeft ArrowRight");
marker.on("dragstart", () => {
element.dataset.dragging = "true";
onDragChange(true);
});
marker.on("dragend", () => {
element.dataset.dragging = "false";
onDragChange(false);
const position = marker.getLngLat();
onChange([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
});
// Custom MapLibre markers own their keyboard interaction. Move in screen
// pixels and consume arrows so adjusting the pin cannot also pan the map.
element.onkeydown = (event) => {
if (!marker.isDraggable() || event.altKey || event.ctrlKey || event.metaKey) return;
const direction = arrowDirections[event.key];
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const point = map.project(marker.getLngLat());
const step = event.shiftKey ? 10 : 1;
const position = map.unproject([point.x + direction[0] * step, point.y + direction[1] * step]);
const next: EventGpsCoordinate = [normalizeLongitude(position.lng), clampLatitude(position.lat)];
marker.setLngLat(next);
onChange(next);
};
return marker;
}
export function normalizeLongitude(longitude: number) {
return ((longitude + 180) % 360 + 360) % 360 - 180;
}
export function clampLatitude(latitude: number) {
return Math.max(-90, Math.min(90, latitude));
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatGpsCoordinate, mergeEventLocation } from "./event-location-coordinate";
describe("event location GPS formatting", () => {
it("formats coordinates with stable GPS precision", () => {
expect(formatGpsCoordinate([121.4791234, 31.2359876])).toBe("121.479123, 31.235988");
});
it("keeps the written location and persists the selected coordinate", () => {
expect(mergeEventLocation(" 演示路口 ", [121.4791234, 31.2359876]))
.toBe("演示路口(GPS:121.479123, 31.235988)");
expect(mergeEventLocation("", [121.4791234, 31.2359876]))
.toBe("GPS:121.479123, 31.235988");
expect(mergeEventLocation(" 演示路口 ", null)).toBe("演示路口");
});
});
@@ -0,0 +1,242 @@
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Input } from "@/shared/ui/input";
import { RotateCcw } from "lucide-react";
import type { Marker, Map as MapLibreMap } from "maplibre-gl";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useId,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject
} from "react";
import { createPortal } from "react-dom";
import { formatGpsCoordinate, type EventGpsCoordinate } from "./event-location-coordinate";
import { EventLocationPin } from "./event-location-pin";
import { clampLatitude, createEventLocationMarker, normalizeLongitude } from "./event-location-marker";
import type { EventLocationAddressLookup } from "./use-event-location-address";
export type { EventGpsCoordinate } from "./event-location-coordinate";
type DragPreview = {
x: number;
y: number;
mapRect: DOMRect;
};
export function EventLocationPicker({ location, addressLookup, coordinate, disabled = false, mapReady, mapRef, onChange }: {
location: string;
addressLookup: EventLocationAddressLookup;
coordinate: EventGpsCoordinate | null;
disabled?: boolean;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onChange: (coordinate: EventGpsCoordinate | null) => void;
}) {
const locationId = useId();
const hintId = useId();
const reducedMotion = useReducedMotion();
const markerRef = useRef<Marker | null>(null);
const markerMapRef = useRef<MapLibreMap | null>(null);
const pointerIdRef = useRef<number | null>(null);
const dragPreviewRef = useRef<DragPreview | null>(null);
const onChangeRef = useRef(onChange);
const [dragPreview, setDragPreview] = useState<DragPreview | null>(null);
const [markerElement] = useState(() => {
const element = document.createElement("button");
element.type = "button";
element.className = "size-11 cursor-grab touch-none border-0 bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! active:cursor-grabbing disabled:cursor-default";
return element;
});
const [markerDragging, setMarkerDragging] = useState(false);
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
const moveDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
if (event.cancelable) event.preventDefault();
const current = dragPreviewRef.current;
if (!current) return;
const next = {
...current,
x: event.clientX,
y: event.clientY
};
dragPreviewRef.current = next;
setDragPreview(next);
};
const finishDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
const map = mapRef.current;
const preview = dragPreviewRef.current;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
if (!map || !preview || !containsPoint(preview.mapRect, event.clientX, event.clientY)) return;
const position = map.unproject([
event.clientX - preview.mapRect.left,
event.clientY - preview.mapRect.top
]);
onChangeRef.current([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
};
const cancelDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
};
window.addEventListener("pointermove", moveDrag, { passive: false });
window.addEventListener("pointerup", finishDrag);
window.addEventListener("pointercancel", cancelDrag);
return () => {
window.removeEventListener("pointermove", moveDrag);
window.removeEventListener("pointerup", finishDrag);
window.removeEventListener("pointercancel", cancelDrag);
};
}, [mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !coordinate) {
if (!coordinate) {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}
return;
}
if (!markerRef.current || markerMapRef.current !== map) {
markerRef.current?.remove();
const marker = createEventLocationMarker({
map, element: markerElement, coordinate, disabled,
onChange: (next) => onChangeRef.current(next),
onDragChange: setMarkerDragging
});
markerRef.current = marker;
markerMapRef.current = map;
} else {
markerRef.current.setLngLat(coordinate);
}
}, [coordinate, disabled, mapReady, mapRef, markerElement]);
useEffect(() => {
markerRef.current?.setDraggable(!disabled);
markerElement.disabled = disabled;
}, [disabled, markerElement]);
useEffect(() => () => {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}, []);
const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
const map = mapRef.current;
if (disabled || !mapReady || !map || !event.isPrimary) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
pointerIdRef.current = event.pointerId;
const mapRect = map.getContainer().getBoundingClientRect();
const preview = {
x: event.clientX,
y: event.clientY,
mapRect
};
dragPreviewRef.current = preview;
setDragPreview(preview);
};
return (
<section aria-label="事件主地图选点" className="mt-5">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 sm:grid-cols-2 sm:gap-x-4">
<div className="min-w-0">
<label htmlFor={locationId} className="block text-xs font-medium text-muted-foreground">事件位置</label>
<Input
id={locationId}
value={location}
onChange={(event) => addressLookup.changeLocation(event.target.value)}
placeholder={addressLookup.pending ? "正在获取地址…" : "选点获取地址,也可手动填写"}
title={location || undefined}
aria-busy={addressLookup.pending}
disabled={disabled}
className="mt-2 h-10 min-w-0"
/>
</div>
<div className="flex h-10 items-center gap-2 self-end">
<button
type="button"
aria-label="拖拽定位图标到主地图"
aria-describedby={hintId}
disabled={disabled || !mapReady}
data-dragging={dragPreview ? "true" : "false"}
className={cn(
"group relative grid size-11 shrink-0 touch-none place-items-center border-0 bg-transparent p-0",
"cursor-grab active:cursor-grabbing focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! disabled:cursor-not-allowed disabled:opacity-45"
)}
onPointerDown={beginDrag}
>
<motion.span
className={cn("block", dragPreview && "opacity-35")}
whileHover={disabled || reducedMotion ? undefined : { rotate: -3 }}
whileTap={disabled || reducedMotion ? undefined : { scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{ transformOrigin: "50% 100%" }}
>
<EventLocationPin />
</motion.span>
</button>
<p id={hintId} className="sr-only text-[11px] leading-4 text-muted-foreground sm:not-sr-only">拖动图钉到主地图选点</p>
</div>
<div className="col-span-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 sm:col-span-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 text-[11px] leading-4">
<span className="text-muted-foreground">GPS</span>
<output aria-label="GPS 坐标" aria-live="polite" className={cn(
"font-mono tabular-nums",
coordinate ? "text-foreground" : "text-muted-foreground"
)}>
{coordinate ? formatGpsCoordinate(coordinate) : mapReady ? "等待拖到主地图" : "主地图加载中"}
</output>
</div>
{coordinate ? (
<Button type="button" size="icon-sm" variant="ghost" aria-label="清除坐标" title="清除坐标" disabled={disabled} onClick={() => onChange(null)}>
<RotateCcw size={13} aria-hidden="true" />
</Button>
) : null}
</div>
</div>
{createPortal(<EventLocationPin raised={markerDragging} />, markerElement)}
{addressLookup.pending ? <p role="status" aria-label="地址查询状态" className="mt-1 text-xs leading-5 text-muted-foreground">正在查询地址…</p> : null}
{addressLookup.error ? (
<div role="status" aria-label="地址查询状态" className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground">
<p>{addressLookup.error}</p>
<Button type="button" variant="link" size="sm" className="px-0" disabled={disabled} onClick={addressLookup.retry}>重试地址查询</Button>
</div>
) : null}
{dragPreview ? createPortal(
<div
aria-hidden="true"
data-event-location-drag-preview=""
data-event-location-drag-pin=""
className="pointer-events-none fixed z-[80] size-11 -translate-x-1/2 -translate-y-full"
style={{ left: dragPreview.x, top: dragPreview.y }}
>
<EventLocationPin />
</div>,
document.body
) : null}
</section>
);
}
function containsPoint(rect: DOMRect, x: number, y: number) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
@@ -0,0 +1,63 @@
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
// One drawing for the picker, drag preview and map marker. The needle ends at
// the bottom centre of the viewBox, matching MapLibre's bottom anchor exactly.
export function EventLocationPin({ raised = false }: { raised?: boolean }) {
const id = useId();
const reducedMotion = useReducedMotion();
return (
<motion.svg
aria-hidden="true"
data-event-location-pin=""
viewBox="0 0 80 80"
className="pointer-events-none block size-11 overflow-visible"
initial={false}
animate={{ rotate: raised && !reducedMotion ? -4 : 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
style={{ transformOrigin: "50% 100%" }}
>
<defs>
<linearGradient id={`${id}-cap`} x1="23" y1="8" x2="53" y2="29" gradientUnits="userSpaceOnUse">
<stop stopColor="#e77c7b" />
<stop offset="0.45" stopColor="#d85157" />
<stop offset="1" stopColor="#b93b48" />
</linearGradient>
<linearGradient id={`${id}-body`} x1="30" y1="27" x2="49" y2="31" gradientUnits="userSpaceOnUse">
<stop stopColor="#df6b6d" />
<stop offset="0.4" stopColor="#d05158" />
<stop offset="1" stopColor="#b33b49" />
</linearGradient>
<linearGradient id={`${id}-foot`} x1="23" y1="35" x2="57" y2="51" gradientUnits="userSpaceOnUse">
<stop stopColor="#e47575" />
<stop offset="0.4" stopColor="#d2525b" />
<stop offset="1" stopColor="#b03b49" />
</linearGradient>
<linearGradient id={`${id}-steel`} x1="36" y1="57" x2="43" y2="59" gradientUnits="userSpaceOnUse">
<stop stopColor="#89949e" />
<stop offset="0.45" stopColor="#d9dfe4" />
<stop offset="1" stopColor="#89949e" />
</linearGradient>
<filter id={`${id}-shadow`} x="-40%" y="-30%" width="190%" height="190%">
<feDropShadow dx="1" dy="1" stdDeviation="1" floodColor="#35151b" floodOpacity="0.12" />
</filter>
<radialGradient id={`${id}-contact`}>
<stop stopColor="#36252b" stopOpacity="0.12" />
<stop offset="1" stopColor="#36252b" stopOpacity="0" />
</radialGradient>
</defs>
<ellipse cx="42" cy="78" rx="12" ry="2" fill={`url(#${id}-contact)`} />
<g transform="rotate(16 40 80)">
<path d="M36.5 47H43.5L41.5 72L40 80L38.5 72Z" fill={`url(#${id}-steel)`} />
<g filter={`url(#${id}-shadow)`}>
<path d="M22 42C22 36 30 32 40 32S58 36 58 42V46C58 52 22 52 22 46Z" fill="#aa3a47" />
<ellipse cx="40" cy="42" rx="18" ry="8" fill={`url(#${id}-foot)`} />
<path d="M29 22H51L47 34C47 38 50 39 52 41C48 46 32 46 28 41C30 39 33 38 33 34Z" fill={`url(#${id}-body)`} />
<path d="M22 17C22 7 58 7 58 17V22C58 32 22 32 22 22Z" fill={`url(#${id}-cap)`} />
<ellipse cx="40" cy="16" rx="18" ry="8" fill={`url(#${id}-cap)`} />
</g>
</g>
</motion.svg>
);
}
@@ -926,6 +926,8 @@ export interface components {
feedback_requirements?: string[] | null;
/** Location */
location?: string | null;
/** Occurred At */
occurred_at?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Progress Note */
@@ -1,5 +1,6 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
CalendarClock,
LoaderCircle,
@@ -25,12 +26,21 @@ import type {
PeripheralSampleClient,
PeripheralWorkOrder
} from "./client";
import {
EventLocationPicker,
type EventGpsCoordinate
} from "./event-location-picker";
import { mergeEventLocation } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
refreshGeneration?: number;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
@@ -89,6 +99,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
busy={busy}
mutate={mutate}
client={client}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveAddress}
onAgentTrigger={onAgentTrigger}
/>
)}
@@ -96,12 +109,15 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
);
}
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
function WorkOrders({ orders, canWrite, busy, mutate, client, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
@@ -116,9 +132,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [feedbackAt, setFeedbackAt] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
const [eventCoordinate, setEventCoordinate] = useState<EventGpsCoordinate | null>(null);
const addressLookup = useEventLocationAddress({ active: eventEditorOpen, coordinate: eventCoordinate, resolveAddress, onChange: setEventLocation });
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
@@ -140,6 +159,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setFeedbackRequirements("");
setProgressNote("");
setFeedbackAt("");
};
const closeEditor = () => {
@@ -182,6 +202,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
setProgressNote("");
setFeedbackAt(completedFeedbackTime(item));
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
@@ -199,26 +220,29 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
const waterQuality = eventKind === "water_quality_complaint";
const submittedLocation = mergeEventLocation(eventLocation, eventCoordinate);
const workOrder = await client.createWorkOrder({
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
title: waterQuality ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
summary: eventDescription.trim(),
status: "open",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
location: submittedLocation,
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
setEventCoordinate(null);
setEventDescription("");
await triggerAgent(workOrder);
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const feedbackTimeChanged = feedbackAt !== (editing ? completedFeedbackTime(editing) : "");
const canSave = canWrite && !busy && title.trim() && summary.trim() && (!feedbackTimeChanged || progressNote.trim());
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
@@ -251,6 +275,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <div className="mt-3"><DateTimeField contentClassName="workbench-shadcn-surface" label="现场反馈时间(可选)" value={feedbackAt} onChange={setFeedbackAt} disabled={!canWrite || Boolean(busy)} /><p className="mt-1 text-[11px] text-muted-foreground">选择实际反馈时间并填写现场反馈。工单完工后,Agent 从该时间起复核 SCADA;留空使用当前时间。</p></div> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -271,7 +296,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
progress_note: progressNote.trim() || null,
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
});
} else {
await client.createWorkOrder({
@@ -334,16 +360,16 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div>
{eventEditorOpen ? (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4 sm:p-5">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="mt-4 grid gap-4 sm:grid-cols-2 [&>div>label]:mb-2 [&>div>label]:leading-4">
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
<EventLocationPicker location={eventLocation} addressLookup={addressLookup} coordinate={eventCoordinate} onChange={setEventCoordinate} disabled={!canWrite || Boolean(busy)} mapReady={mapReady} mapRef={mapRef} />
<label className="mt-5 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-2 min-h-20" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-5 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || addressLookup.pending || (!eventLocation.trim() && !eventCoordinate) || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
</Button>
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
@@ -574,3 +600,9 @@ function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
function completedFeedbackTime(item: PeripheralWorkOrder): string {
if (item.status !== "completed") return "";
const feedback = (item.activity ?? []).filter((entry) => entry.kind === "feedback").at(-1);
return feedback ? toLocalInput(new Date(feedback.occurred_at)) : "";
}
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
import { reverseGeocodePoint } from "../api/place-search-client";
import type { EventLocationAddressResolver } from "./use-event-location-address";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
@@ -21,12 +24,18 @@ export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
onAgentTrigger
mapReady,
mapRef,
onAgentTrigger,
onOpenInvestigation
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -37,6 +46,10 @@ export function PeripheralSamplePanel({
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [scadaRefreshGeneration, setScadaRefreshGeneration] = useState(0);
const [activeSystem, setActiveSystem] = useState<"work-orders" | "scada">("work-orders");
const resolveEventAddress = useCallback<EventLocationAddressResolver>(async ([longitude, latitude], signal) => {
const result = await reverseGeocodePoint(longitude, latitude, context?.project_id ?? null, getAccessToken, signal);
return result.address.trim() || result.name;
}, [context?.project_id, getAccessToken]);
const refresh = useCallback(async () => {
if (!client.configured) return;
@@ -120,10 +133,14 @@ export function PeripheralSamplePanel({
{context ? (
<div className="space-y-3">
<ManagedTestResources
key={context.project_id}
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
refreshGeneration={refreshGeneration}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveEventAddress}
onAgentTrigger={onAgentTrigger}
/>
<Disclosure
@@ -168,6 +185,7 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"}
getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration}
onOpenInvestigation={onOpenInvestigation}
/>
</TabsContent>
</Tabs>
@@ -0,0 +1,82 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
const preference = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...await original<typeof import("motion/react")>(), useReducedMotion: () => preference.reduced
}));
const result: ScadaMonitoringResult = {
monitoring_id: "70000000-0000-4000-8000-000000000001", period_start: "2026-07-15T23:45:00Z",
period_end: "2026-07-16T00:00:00Z", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 2,
rule_version: "burst-monitor-v2", status: "attention", task_id: "80000000-0000-4000-8000-000000000001",
detail: "详细压力与流量依据应保留在报告中", pressure: {}, outlet_flow: {}, burst_detection: {},
weighted: { score: 0.1, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", action: "raised", status: "active",
started_at: "2026-07-16T00:00:00Z", last_observed_at: "2026-07-16T00:00:00Z", recovery_count: 0 }
};
afterEach(() => { cleanup(); vi.useRealTimers(); preference.reduced = false; });
it("keeps notifications short and opens the existing investigation", () => {
const onOpen = vi.fn();
render(<ScadaMonitoringNotice result={result} animationId={null} processing={false} onOpenInvestigation={onOpen} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByText("已启动调查")).toBeVisible();
expect(screen.queryByText(result.detail)).not.toBeInTheDocument();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "warning");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onOpen).toHaveBeenCalledWith(result.task_id);
});
it("uses danger color only when the monitoring decision supports suspected burst", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "suspected_burst" }} animationId={null} processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "danger");
});
it("finishes the one-time animation without restarting for the next period of the same incident", () => {
vi.useFakeTimers();
const { rerender } = render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "running");
act(() => vi.advanceTimersByTime(1000));
rerender(<ScadaMonitoringNotice result={{ ...result, incident: { ...result.incident!, action: "ongoing" } }}
animationId="accident-1" processing={false} />);
act(() => vi.advanceTimersByTime(1600));
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByText("沿用原调查")).toBeVisible();
});
it("retains the anomaly message with reduced motion and suppresses animation", () => {
preference.reduced = true;
render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status").querySelector("[data-slot=scada-alert-emphasis]")).toBeNull();
});
it("shows a neutral standby state before monitoring starts", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing={false} />);
expect(screen.getByText("监测待机")).toBeVisible();
expect(screen.getByText("等待播放或单步监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "neutral");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
it("shows pending progress while waiting for the first monitoring result", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing />);
expect(screen.getByText("正在监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
expect(screen.queryByText("监测待机")).not.toBeInTheDocument();
});
it("uses success styling for a normal result and keeps its timestamp during the next check", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "normal", task_id: null, incident: null }} animationId={null} processing />);
expect(screen.getByText("监测正常")).toBeVisible();
expect(screen.getByText("本周期未发现异常")).toBeVisible();
expect(screen.getByText("正在监测下一周期")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "success");
expect(screen.getByText(/07\/16/)).toHaveAttribute("dateTime", result.period_end);
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
export function ScadaMonitoringNotice({ result, animationId, processing, onOpenInvestigation }: {
result: ScadaMonitoringResult | null | undefined;
animationId: string | null;
processing: boolean;
onOpenInvestigation?: (taskId: string) => void;
}) {
const reduced = useReducedMotion();
const [animating, setAnimating] = useState(false);
useEffect(() => {
if (!animationId || reduced) return;
setAnimating(true);
const timer = window.setTimeout(() => setAnimating(false), 2600);
return () => window.clearTimeout(timer);
}, [animationId, reduced]);
const recovered = result?.incident?.action === "resolved";
const ongoing = result?.incident?.action === "ongoing";
const abnormal = Boolean(result && !recovered && (result.task_id || ["attention", "suspected_burst"].includes(result.status)));
const suspectedBurst = abnormal && result?.status === "suspected_burst";
const normal = result?.status === "normal" || recovered;
const tone: StatusTone = suspectedBurst ? "danger" : abnormal ? "warning" : normal ? "success" : processing ? "info" : "neutral";
const Icon = !result ? processing ? LoaderCircle : Clock3 : abnormal ? TriangleAlert : normal ? CheckCircle2 : CircleHelp;
const title = !result ? processing ? "正在监测" : "监测待机"
: recovered ? "异常已恢复" : ongoing ? "异常持续跟踪" : suspectedBurst ? "发现疑似爆管" : abnormal ? "发现指标异常"
: normal ? "监测正常" : "监测数据不足";
const note = !result ? processing ? "正在核对本周期数据" : "等待播放或单步监测"
: result.task_id ? recovered ? "告警已解除" : ongoing ? "沿用原调查" : "已启动调查"
: normal ? "本周期未发现异常" : abnormal ? "请留意后续监测结果" : "暂无法形成监测结论";
const hint = processing ? result ? "正在监测下一周期" : "处理完成后更新结果"
: abnormal ? "诊断详情见调查报告" : recovered ? "可回看本次调查"
: result && !normal ? "待后续周期继续监测" : "异常时自动启动调查";
const emphasis = animating && abnormal && !reduced;
return (
<section aria-label="最新监测结果" role="status" aria-busy={processing}
aria-atomic="true"
data-animation-state={emphasis ? "running" : "idle"} data-announcement-id={animationId ?? undefined}
data-status-tone={tone}
className={cn(
"relative isolate grid h-40 shrink-0 grid-rows-[1.25rem_1fr_2.25rem] gap-2 overflow-hidden rounded-lg bg-[var(--status-soft)] p-3.5 text-foreground ring-1 ring-inset ring-[var(--status-border)]",
`status-tone-${tone}`
)}>
{emphasis ? <motion.div key={`${animationId}-surface`} aria-hidden="true"
data-slot="scada-alert-emphasis" className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.55, 0] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<div data-slot="scada-notice-header" className="flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/75">
<span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<span className="sr-only">最新监测 </span>
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })}
</time> : <span>尚无监测结果</span>}
</div>
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
<span className="relative shrink-0" aria-hidden="true">
{emphasis ? <motion.span key={animationId} data-slot="scada-alert-pulse"
className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: [0, 0.65, 0], scale: [0.8, 1.35, 1.65] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<StatusIcon tone={tone} size="md" className="size-9 bg-background/70"
activity={!result && processing ? "loading" : "static"}>
<motion.span key={`${animationId}-icon`} animate={emphasis ? { rotate: [0, -12, 12, -6, 0] } : { rotate: 0 }}
transition={{ duration: 0.5 }}><Icon size={18} /></motion.span>
</StatusIcon>
</span>
<div data-slot="scada-notice-summary" className="min-w-0 flex-1 space-y-0.5 text-xs leading-5">
<p className="font-semibold">{title}</p>
<p className="text-foreground/75">{note}</p>
</div>
</div>
<div data-slot="scada-notice-footer" className="flex min-w-0 items-center justify-between gap-2 border-t border-[var(--status-border)] pt-1.5">
<p className="text-[11px] leading-4 text-foreground/75">{hint}</p>
<div data-slot="scada-notice-action" className="flex h-8 w-[5.5rem] shrink-0 justify-end">
{result?.task_id && onOpenInvestigation ? <Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
onClick={() => onOpenInvestigation(result.task_id!)}>
查看调查<ArrowUpRight size={12} aria-hidden="true" />
</Button> : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,74 @@
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: {
config: ScadaMonitoringConfig;
calibration?: ScadaMonitoringCalibration | null;
frozen: boolean;
}) {
const weighted = config.decision_policy === "weighted";
const currentPolicy = calibration?.version === "scada-monitor-calibration@2"
&& calibration.diagnostic_policy === "normal-phase-envelope@1";
const usable = currentPolicy ? calibration : null;
const ready = usable?.status === "ready";
const indicators = [
{ key: "burst_detection", label: "爆管侦测", threshold: ready && usable.burst_detection?.threshold != null
? `得分 ≤ ${usable.burst_detection.threshold.toFixed(4)}` : "由前 15 天正常历史计算" },
{ key: "pressure", label: "压力下降", threshold: thresholdLabel(config.pressure_device_ids, usable?.pressure, "下降") },
{ key: "outlet_flow", label: "出厂流量", threshold: config.outlet_flow_device_ids.length
? thresholdLabel(config.outlet_flow_device_ids, usable?.outlet_flow, "增加") : "未配置,贡献为 0" }
] as const;
return (
<section aria-label="组合监测规则" className="space-y-3 text-xs leading-5">
<div className="space-y-1">
<h4 className="font-semibold text-foreground">SCADA 异常触发规则</h4>
<p className="text-muted-foreground">压力、流量越过历史同期正常范围,或水质越过已配置上下限,即报警并启动调查。</p>
<ol aria-label="诊断流程" className="flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<li>数据诊断</li><li aria-hidden="true">→</li><li>指标异常</li><li aria-hidden="true">→</li><li>爆管诊断</li><li aria-hidden="true">→</li><li>报告</li>
</ol>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h4 className="font-semibold text-foreground">{weighted ? "爆管诊断 · 三指标加权" : "爆管诊断 · 三指标联合判定"}</h4>
<span className="text-[11px] text-muted-foreground">{frozen ? "本轮已固定" : "下一轮使用项目配置"}</span>
</div>
{weighted ? (
<>
<dl className="divide-y divide-border">
{indicators.map(({ key, label, threshold }) => (
<div key={key} className="grid grid-cols-[1fr_auto] gap-x-3 py-2 first:pt-0">
<dt className="text-foreground">{label}</dt>
<dd className="font-medium text-foreground tabular-nums">权重 {(config.weights[key] * 100).toFixed(0)}%</dd>
<dd className="col-span-2 text-[11px] text-muted-foreground tabular-nums">{threshold}</dd>
</div>
))}
</dl>
<p className="font-medium text-foreground tabular-nums">综合分 ≥ {config.alert_threshold.toFixed(2)} 时标记疑似爆管</p>
<p className="text-[11px] text-muted-foreground">加权分用于评估爆管嫌疑,任一指标异常即启动调查。</p>
<p className="text-[11px] text-muted-foreground">压力下降与出厂流量增加需同时越过动态变化阈值和历史同期正常范围。正常日的日内波动由同期范围校验。</p>
<p className="text-[11px] text-muted-foreground">指标命中后计入对应权重;流量缺数按 0 计,不重新分配权重。压力或侦测数据不足时暂不作出组合判定。</p>
<div className="space-y-1 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">动态阈值 · {ready ? "本轮已冻结" : "等待历史基线"}</p>
{ready ? <p className="tabular-nums">历史区间 {dateTime(usable.history_start)} 至 {dateTime(usable.knowledge_cutoff)}(不含结束时刻)</p> : <p>{calibration && !currentPolicy ? "旧版校准已停用,请重新开始本轮。" : calibration ? "历史基线不足,动态阈值尚未就绪。" : "开始监测后,以演示开始前 15 天的正常历史逐测点计算阈值,并在本轮固定。"}</p>}
</div>
</>
) : (
<p className="text-muted-foreground">本轮使用三指标联合评估爆管嫌疑;任一经历史基线校验的指标异常即启动调查。</p>
)}
<p className="text-[11px] text-muted-foreground">同一异常只报警并建单一次,持续异常沿用原工单;连续两个正常周期且相关测点恢复后解除告警。</p>
<p className="text-[11px] text-muted-foreground tabular-nums">压力 {config.pressure_device_ids.length} 个测点 · 出厂流量 {config.outlet_flow_device_ids.length} 个测点 · {config.rule_version}</p>
</section>
);
}
function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pressure"] | undefined, direction: string) {
const thresholds = ids.flatMap((id) => profiles?.[id] ? [profiles[id].threshold_percent] : []);
if (!thresholds.length) return "逐测点动态计算变化阈值";
const lower = Math.min(...thresholds).toFixed(2);
const upper = Math.max(...thresholds).toFixed(2);
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
}
function dateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
@@ -0,0 +1,37 @@
import { Activity, Clock3, Gauge, Timer } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { SCADA_PLAYBACK_SPEED } from "./use-scada-playback";
import { formatMonitoringInterval } from "./scada-replay-time";
export function ScadaMonitoringStatus({ status, playing, processing, intervalSeconds, count, progress }: {
status: string;
playing: boolean;
processing: boolean;
intervalSeconds?: number | null;
count: number;
progress: number;
}) {
return <section aria-label="SCADA 演示状态" className="space-y-3 rounded-lg bg-muted/60 p-3.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<StatusBadge tone={playing || processing ? "info" : "neutral"} icon={<Activity size={13} />}>
{status}
</StatusBadge>
<span className="text-xs text-muted-foreground tabular-nums">已监测 <strong className="font-medium text-foreground">{count}</strong> 个周期</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
{[
{ label: "监测周期", value: intervalSeconds ? formatMonitoringInterval(intervalSeconds) : "未配置", Icon: Clock3 },
{ label: "演示倍速", value: `${SCADA_PLAYBACK_SPEED}×`, Icon: Gauge },
{ label: "每步播放", value: intervalSeconds ? `约 ${Number((intervalSeconds / SCADA_PLAYBACK_SPEED).toFixed(1))} 秒` : "待配置", Icon: Timer }
].map(({ label, value, Icon }) => <div key={label} className="min-w-0 space-y-1">
<dt className="flex flex-wrap items-center gap-1 text-[11px] leading-5 text-muted-foreground"><Icon size={12} aria-hidden="true" />{label}</dt>
<dd className="font-semibold leading-6 tabular-nums text-foreground">{value}</dd>
</div>)}
</dl>
<div role="progressbar" aria-label="演示区间进度" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)} className="h-1 overflow-hidden rounded-full bg-border">
<div className="h-full origin-left bg-primary" style={{ transform: `scaleX(${progress})` }} />
</div>
<p className="text-[11px] leading-5 text-muted-foreground">每步另需后台处理时间;关闭面板或切到后台会暂停播放。</p>
</section>;
}
@@ -1,8 +1,8 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } 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 type { ScadaMonitoringConfig, ScadaReplay } from "../api/scada-replay-client";
import { reuseReplayProjection, reuseScadaReadings } from "./scada-replay-readings";
import { ScadaReplaySection } from "./scada-replay-section";
@@ -38,6 +38,12 @@ const catalog: LatestScadaReadings = {
latest_observed_at: "2026-10-22T01:00:00Z"
};
const projectRules: ScadaMonitoringConfig = {
rule_version: "burst-monitor-v2", decision_policy: "weighted",
pressure_device_ids: ["P1"], outlet_flow_device_ids: ["F1"],
weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 }, alert_threshold: 0.8
};
function replay(): ScadaReplay {
return {
replay_id: "60000000-0000-4000-8000-000000000001",
@@ -116,6 +122,53 @@ describe("SCADA replay panel", () => {
expect(command).not.toHaveBeenCalled();
});
it("returns the pointer to the range beginning without resetting backend progress", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: Date.parse("2026-10-22T01:15:00Z") } });
fireEvent.click(screen.getByRole("button", { name: "回到开始时间" }));
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(current.start_time));
expect(screen.getByRole("button", { name: "回到开始时间" })).toBeDisabled();
expect(current.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
});
it("monitors the pointer's exact selected time without sending a next-step command", async () => {
const target = Date.parse("2026-10-22T01:05:00Z");
const backend = useReplay();
const monitorAt = vi.fn(async () => ({ ...current, virtual_now: new Date(target).toISOString() }));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(expect.objectContaining({ replay_id: current.replay_id }), new Date(target).toISOString()));
expect(command).not.toHaveBeenCalled();
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target);
});
it("prepares a separate causal round before monitoring a historical selection", async () => {
const original = current;
const target = Date.parse("2026-10-22T00:15:00Z");
const backend = useReplay();
const created = { ...original, replay_id: "60000000-0000-4000-8000-000000000002", status: "ready", virtual_now: original.start_time } as ScadaReplay;
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => created);
const monitorAt = vi.fn(async () => ({ ...created, status: "paused", virtual_now: new Date(target).toISOString() } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, command: stop, create, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
expect(screen.getByText("监测此时刻将建立新一轮回放,原有调查记录保留。")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(created, new Date(target).toISOString()));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create.mock.invocationCallOrder[0]).toBeLessThan(monitorAt.mock.invocationCallOrder[0]);
expect(original.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target));
});
it("retains new evidence and separates otherwise identical replay runs", () => {
const incoming = structuredClone(current.latest);
expect(reuseScadaReadings(current.latest, incoming)).toBe(current.latest);
@@ -135,6 +188,8 @@ describe("SCADA replay panel", () => {
expect(screen.getByLabelText("回看位置测试输入")).toHaveAttribute("data-step-ms", "300000");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("15 分钟");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("180×");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("5 秒");
expect(screen.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
});
@@ -146,6 +201,109 @@ describe("SCADA replay panel", () => {
expect(command).toHaveBeenCalledWith("step");
});
it("shows project weighted rules before starting without claiming learned thresholds", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null,
capabilities: { ...backend.capabilities, monitoring_config: projectRules } });
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("权重 50%")).toBeVisible();
expect(screen.getByText("权重 40%")).toBeVisible();
expect(screen.getByText("权重 10%")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("shows the active replay frozen rules and learned thresholds instead of current project settings", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend,
capabilities: { ...backend.capabilities, monitoring_config: { ...projectRules, alert_threshold: 0.7 } } });
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "ready", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 6.5 } }, outlet_flow: { F1: { threshold_percent: 8.5 } },
burst_detection: { threshold: -0.0843 }
};
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("本轮已固定")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("得分 ≤ -0.0843")).toBeVisible();
expect(screen.getByText("下降阈值 6.50%")).toBeVisible();
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.queryByText("变化阈值 30%")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("identifies an explicitly configured legacy combined policy", () => {
current.monitoring_config = { ...projectRules, decision_policy: "all_signals" };
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("爆管诊断 · 三指标联合判定", { selector: "h4" })).toBeVisible();
expect(screen.queryByText("权重 50%")).not.toBeInTheDocument();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("defaults a new demonstration to one hour around the Case3 event", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null });
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00"));
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("stops an old replay before switching its display to the Case3 hour", async () => {
const backend = useReplay();
const stop = vi.fn(async () => {
current = { ...current, status: "stopped" };
return current;
});
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop }, change: (operation: () => Promise<ScadaReplay>) => operation() }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "切换到 Case3" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00")));
expect(stop).toHaveBeenCalledWith(expect.objectContaining({ status: "paused" }), "stop");
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("retains learned flow thresholds while pressure baseline is still incomplete", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "insufficient_data", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: {}, outlet_flow: { F1: { threshold_percent: 8.5 } }
};
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.getByText("加权分用于评估爆管嫌疑,任一指标异常即启动调查。")).toBeVisible();
});
it("requires rebuilding a warmed replay with obsolete calibration", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@1", status: "ready",
history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 30 } }, outlet_flow: {}
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本轮使用旧版诊断")).toBeVisible();
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一步并监测" })).toBeDisabled();
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("旧版校准已停用,请重新开始本轮。")).toBeVisible();
expect(screen.queryByText("下降阈值 30.00%")).not.toBeInTheDocument();
});
it("shows the persisted monitoring decision and linked burst investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
@@ -165,8 +323,80 @@ describe("SCADA replay panel", () => {
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本周期疑似爆管")).toBeVisible();
expect(screen.getByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).toBeVisible();
expect(screen.getByText("已进入爆管调查与定位流程")).toBeVisible();
expect(screen.getByText("发现疑似爆管")).toBeVisible();
expect(screen.queryByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).not.toBeInTheDocument();
expect(screen.getByText("已启动调查")).toBeVisible();
});
it("shows the continuing accident and weighted evidence without offering another 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-v2",
status: "insufficient_data", detail: "本周期数据不足,继续跟踪原事故",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: { status: "insufficient_data", hit: false }, outlet_flow: { status: "ready", hit: false },
burst_detection: { status: "insufficient_data", hit: false },
weighted: { score: 0, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", status: "active", action: "ongoing",
started_at: "2026-10-22T00:30:00Z", last_observed_at: "2026-10-22T01:00:00Z", recovery_count: 0 }
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("异常持续跟踪")).toBeVisible();
expect(screen.getByText("沿用原调查")).toBeVisible();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.queryByText("判定依据")).not.toBeInTheDocument();
expect(command).not.toHaveBeenCalled();
});
it("separates play and pause controls and keeps the play label after pausing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
const play = screen.getByRole("button", { name: "播放" });
const pause = screen.getByRole("button", { name: "暂停" });
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
fireEvent.click(play);
expect(play).toBeDisabled();
expect(pause).toBeEnabled();
fireEvent.click(pause);
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
expect(command).not.toHaveBeenCalled();
});
it("stops the previous round and prepares a fresh paused round without stepping it", async () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const original = current;
const backend = useReplay();
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => ({ ...original, replay_id: "60000000-0000-4000-8000-000000000002",
status: "ready", virtual_now: original.start_time, monitoring_count: 0, processed_count: 0, last_monitoring: null } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop, create },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "重新开始" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(original.start_time)));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create).toHaveBeenCalledWith(expect.objectContaining({ start_time: new Date(original.start_time).toISOString(), end_time: new Date(original.end_time).toISOString(), device_ids: ["P1"] }));
expect(command).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "播放" })).toBeEnabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveTextContent("监测待机");
});
it("requires an explicit restart after completion and blocks restart during pending processing", () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const backend = useReplay();
current = { ...current, status: "completed", start_time: "2026-07-15T23:30:00Z", end_time: "2026-07-16T00:30:00Z", virtual_now: "2026-07-16T00:30:00Z" };
useReplay.mockImplementation(() => ({ ...backend, replay: current }));
const { rerender } = render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("button", { name: "重新开始" })).toBeEnabled();
current = { ...current, status: "paused", step_pending: true };
rerender(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "重新开始" })).toBeDisabled();
});
});
@@ -1,30 +1,32 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
CheckCircle2,
ChevronDown,
CircleHelp,
RotateCcw,
SkipForward,
Square,
TriangleAlert
Pause,
Play
} 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 { snapTimelineTime } from "./scada-replay-time";
import { ScadaTimeTuner } from "./scada-time-tuner";
import { ScadaMonitoringRules } from "./scada-monitoring-rules";
import { useScadaReplay } from "./use-scada-replay";
import { useScadaPlayback } from "./use-scada-playback";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
import { ScadaMonitoringStatus } from "./scada-monitoring-status";
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 CASE3_START = Date.parse("2026-07-16T07:30:00+08:00");
const CASE3_END = Date.parse("2026-07-16T08:30:00+08:00");
const STATUS: Record<ScadaReplay["status"], string> = {
ready: "待监测",
paused: "等待下一步",
@@ -32,50 +34,22 @@ const STATUS: Record<ScadaReplay["status"], string> = {
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
getAccessToken,
onOpenInvestigation
}: {
active: boolean;
catalog: LatestScadaReadings;
getAccessToken?: WorkbenchAccess;
onOpenInvestigation?: (taskId: string) => void;
}) {
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 [start, setStart] = useState(() => localTime(CASE3_START));
const [end, setEnd] = useState(() => localTime(CASE3_END));
const [threshold, setThreshold] = useState("30");
const [qualityMetric, setQualityMetric] = useState("");
const [qualityLower, setQualityLower] = useState("");
@@ -85,22 +59,28 @@ export function ScadaReplaySection({
));
const [view, setView] = useState<number | null>(null);
const restoredId = useRef<string | undefined>(undefined);
const announced = useRef(new Set<string>());
const [animationId, setAnimationId] = useState<string | null>(null);
const [monitoringSelection, setMonitoringSelection] = useState(false);
const locked = Boolean(replay && ACTIVE.has(replay.status));
const rulesFrozen = Boolean(replay && ["ready", "paused", "failed"].includes(replay.status));
const monitoringConfig = rulesFrozen ? replay?.monitoring_config : capabilities?.monitoring_config;
const obsoleteCalibration = Boolean(rulesFrozen && replay?.monitoring_config?.decision_policy === "weighted"
&& (replay.processed_count > 0 || replay.monitoring_calibration)
&& (replay.monitoring_calibration?.version !== "scada-monitor-calibration@2"
|| replay.monitoring_calibration?.diagnostic_policy !== "normal-phase-envelope@1"));
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 matchingReplay = replay && Date.parse(replay.start_time) === startMs && Date.parse(replay.end_time) === endMs
? replay : null;
const case3Range = startMs === CASE3_START && endMs === CASE3_END;
const now = matchingReplay ? Date.parse(matchingReplay.virtual_now) : startMs;
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>();
@@ -157,6 +137,7 @@ export function ScadaReplaySection({
useEffect(() => {
if (!replay || restoredId.current === replay.replay_id) return;
restoredId.current = replay.replay_id;
if (!["ready", "paused", "failed"].includes(replay.status)) return;
setView(null);
setStart(localTime(Date.parse(replay.start_time)));
setEnd(localTime(Date.parse(replay.end_time)));
@@ -169,47 +150,146 @@ export function ScadaReplaySection({
setQualityUpper(water?.upper == null ? "" : String(water.upper));
}, [replay]);
const advance = () => {
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
return backend.command("step");
const announceMonitoringResult = (next: ScadaReplay | null) => {
const result = next?.last_monitoring;
if (result && (result.task_id || ["attention", "suspected_burst"].includes(result.status))
&& !["ongoing", "resolved"].includes(result.incident?.action ?? "")) {
const id = result.incident?.incident_id ?? result.task_id ?? result.monitoring_id;
if (!announced.current.has(id)) {
announced.current.add(id);
setAnimationId(id);
}
}
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
};
const advance = async (): Promise<ScadaReplay | null> => {
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
let next: ScadaReplay | null;
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
next = await backend.command("step");
} else {
if (!valid) return null;
next = await 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");
});
return backend.client.command(created, "step");
});
}
announceMonitoringResult(next);
return next;
};
const canMonitor = Boolean(interval && capabilities?.available && capabilities.can_write && (locked || valid)
&& !backend.error && !obsoleteCalibration
&& (!replay || ["ready", "paused", "completed", "stopped"].includes(replay.status)));
const playback = useScadaPlayback({
active, enabled: canMonitor, busy: busy || Boolean(replay?.step_pending),
start: startMs, end: endMs, now, intervalSeconds: interval,
restart: Boolean(replay && ["completed", "stopped"].includes(replay.status)), advance
});
const displayTime = view === null && playback.started ? playback.time
: snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
const future = Boolean(
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
);
const selectionNeedsNewRound = Boolean(matchingReplay && (displayTime < now
|| ["stopped", "failed"].includes(matchingReplay.status) || obsoleteCalibration));
const monitorSelection = async () => {
playback.pause();
const target = new Date(displayTime).toISOString();
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
setMonitoringSelection(true);
try {
const next = await backend.change(async () => {
let current = matchingReplay;
if (!current || selectionNeedsNewRound) {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
current = 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.monitorAt(current, target);
});
announceMonitoringResult(next);
} finally {
setMonitoringSelection(false);
}
};
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));
const selectCase3 = () => {
playback.pause();
const apply = () => {
setStart(localTime(CASE3_START));
setEnd(localTime(CASE3_END));
setView(null);
setSelectedTypes(new Set(catalog.items.map((item) => item.device_type)));
};
if (rulesFrozen && replay) {
return backend.change(async () => {
const stopped = await backend.client.command(replay, "stop");
apply();
return stopped;
});
}
apply();
};
const restarting = playback.newRound;
const requiresRestart = Boolean(matchingReplay && ["completed", "stopped"].includes(matchingReplay.status));
const restart = async () => {
playback.pause();
const next = await backend.change(async () => {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
return backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
});
if (next) {
setView(null);
setAnimationId(null);
playback.reset();
}
};
const result = restarting ? null : matchingReplay?.last_monitoring;
const nextTime = interval ? Math.min(endMs,
startMs + (Math.floor(((restarting ? startMs : now) - startMs) / (interval * 1000)) + 1) * interval * 1000
) : null;
const stepDisabled = busy || replay?.step_pending || !canMonitor || requiresRestart;
const restartDisabled = busy || replay?.step_pending || !capabilities?.available || !capabilities.can_write
|| !valid || (!matchingReplay && !playback.started);
const selectionDisabled = busy || replay?.step_pending || playback.playing
|| !capabilities?.available || !capabilities.can_write || !valid;
return (
<WorkbenchSection
title="SCADA 监测演示"
description="时间轴按 5 分钟定位,监测按钮按项目周期触发异常分析。"
title="SCADA 数据诊断"
description="自动播放并按周期监测,发现异常后启动调查。"
icon={<Activity size={15} />}
tone="teal"
bodyClassName="space-y-4"
bodyClassName="space-y-3"
>
{capabilities && !capabilities.available ? (
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
{capabilities.reason}
</WorkbenchNotice>
) : null}
{backend.error || replay?.error ? (
{backend.error || matchingReplay?.error ? (
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
{backend.error ?? replay?.error}
{backend.error ?? matchingReplay?.error}
</WorkbenchNotice>
) : null}
{obsoleteCalibration ? (
<WorkbenchNotice tone="warning" title="本轮使用旧版诊断">
旧版判断已停用,请重新开始本轮,以使用历史同期正常范围。
</WorkbenchNotice>
) : null}
{capabilities && !interval ? (
@@ -223,221 +303,135 @@ export function ScadaReplaySection({
</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>
<ScadaMonitoringStatus
status={replay?.step_pending || busy ? "正在处理监测数据" : playback.playing ? "自动播放中" : matchingReplay?.status === "completed" && !restarting ? "播放完成" : playback.started ? "播放已暂停" : matchingReplay ? STATUS[matchingReplay.status] : "尚未开始"}
playing={playback.playing}
processing={busy || Boolean(replay?.step_pending)}
intervalSeconds={interval}
count={restarting ? 0 : matchingReplay?.monitoring_count ?? 0}
progress={Number.isFinite(startMs) && endMs > startMs
? Math.max(0, Math.min(1, ((playback.started ? playback.time : now) - startMs) / (endMs - startMs))) : 0}
/>
<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>
<ScadaMonitoringNotice result={result} animationId={animationId}
processing={busy || Boolean(replay?.step_pending)}
onOpenInvestigation={onOpenInvestigation ? (taskId) => { playback.pause(); onOpenInvestigation(taskId); } : undefined} />
{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}
/>
<section aria-label="SCADA 回放控制" className="overflow-hidden rounded-xl border border-border bg-background">
<div className="space-y-3 p-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>Case3 · 事件 07/16 08:00 · 北京时间</span>
{case3Range ? <span>事件前后各 30 分钟</span> : <Button size="sm" variant="ghost"
disabled={busy || replay?.step_pending || !capabilities?.can_write}
onClick={() => void selectCase3()}>切换到 Case3</Button>}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<DateTimeField
label="演示开始时间"
value={locked && replay ? localTime(startMs) : start}
onChange={(value) => { playback.pause(); setStart(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
<DateTimeField
label="演示结束时间"
value={locked && replay ? localTime(endMs) : end}
onChange={(value) => { playback.pause(); setEnd(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
</div>
</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="space-y-2 border-t border-border p-3">
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
<ScadaTimeTuner
start={startMs}
end={endMs}
value={Math.max(startMs, Math.min(endMs, displayTime))}
step={TIMELINE_STEP_MS}
following={playback.started && view === null}
browsing={view !== null}
onInteract={playback.pause}
onChange={(value) => { playback.pause(); setView(value); }}
/>
) : null}
<div className="flex min-h-9 flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>{view !== null
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
: "拨动刻度只定位时间,不会自动触发监测"}</span>
<Button size="sm" variant="ghost" disabled={displayTime === startMs}
onClick={() => { playback.pause(); setView(startMs); }}>回到开始时间</Button>
</div>
<div role="group" aria-label="SCADA 播放控制" className="mx-auto grid w-full max-w-sm grid-cols-3 gap-2">
<Button size="sm" className="h-10 gap-1.5 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { setView(null); playback.play(); }}><Play size={14} />播放</Button>
<Button size="sm" variant="outline" className="h-10 gap-1.5 px-2" disabled={!playback.playing}
onClick={playback.pause}><Pause size={14} />暂停</Button>
<Button size="sm" variant="ghost" className="h-10 px-2" disabled={restartDisabled}
onClick={() => void restart()}>重新开始</Button>
</div>
</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 className="space-y-2 border-t border-border bg-muted/30 p-3">
{selectionNeedsNewRound ? <p className="text-[11px] leading-5 text-muted-foreground">
监测此时刻将建立新一轮回放,原有调查记录保留。
</p> : null}
<div role="group" aria-label="SCADA 单步监测" className="grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" className="h-10 px-2" disabled={selectionDisabled}
onClick={() => void monitorSelection()}>
{monitoringSelection ? "正在监测" : "监测选定时刻"}
</Button>
<Button size="sm" variant="outline" className="h-10 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { playback.pause(); setView(null); void advance(); }}>
{!monitoringSelection && (replay?.step_pending || busy) ? "正在监测本步" : locked ? "下一步并监测" : "单步监测"}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="space-y-0.5 text-[11px] leading-5 text-muted-foreground tabular-nums">
<p role="status" aria-label="SCADA 监测进度">
已处理至 {matchingReplay && !restarting && processedDisplayTime !== null
? stamp(new Date(processedDisplayTime).toISOString()) : "未开始"}
</p>
{nextTime !== null ? <p>{matchingReplay?.status === "completed" && !restarting
? "本轮监测已完成" : `下次监测 ${clock(new Date(nextTime).toISOString())}`}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-1">
{replay && ["ready", "paused", "failed"].includes(replay.status) ? <Button
size="sm" variant="ghost" className="h-10 px-2" disabled={busy || !capabilities?.can_write}
onClick={() => { playback.pause(); void backend.command("stop"); }}><Square size={13} />停止本轮</Button> : null}
{replay?.status === "failed" ? <Button size="sm" variant="outline" className="h-10 px-2"
disabled={busy || !capabilities?.can_write} onClick={() => { playback.pause(); void backend.command("retry"); }}>
<RotateCcw size={13} />重试本步</Button> : null}
</div>
</div>
</section>
) : null}
</div>
</section>
<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} />检测参数
<ChevronDown size={13} />检测规则
<span className="font-normal text-muted-foreground">
{selectedDevices.length} 个测点 · {rules.length} 项规则
{monitoringConfig ? "任一指标异常即调查" : `${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}
{monitoringConfig ? <ScadaMonitoringRules
config={monitoringConfig}
calibration={rulesFrozen ? replay?.monitoring_calibration : null}
frozen={rulesFrozen}
/> : <p className="text-xs text-muted-foreground">任一已配置指标异常即启动调查。项目暂未返回组合监测配置。</p>}
</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 stamp(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
function localTime(time: number) {
@@ -450,7 +444,3 @@ function clock(value?: string | null) {
? 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;
}
@@ -8,11 +8,13 @@ import { ScadaReplaySection } from "./scada-replay-section";
export function ScadaSystemSection({
active,
getAccessToken,
refreshGeneration
refreshGeneration,
onOpenInvestigation
}: {
active: boolean;
getAccessToken?: WorkbenchAccess;
refreshGeneration: number;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null);
@@ -80,6 +82,7 @@ export function ScadaSystemSection({
active={active}
getAccessToken={getAccessToken}
catalog={catalog}
onOpenInvestigation={onOpenInvestigation}
/>
) : null}
</div>
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "motion/react";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
@@ -10,35 +10,61 @@ 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;
const SNAP_DURATION_SECONDS = 0.22;
const SNAP_EASE = [0.22, 1, 0.36, 1] as const;
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange }: {
export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value, step, following, browsing = false, onChange, onInteract }: {
start: number; end: number; value: number; step: number; following: boolean; browsing?: boolean; onChange: (time: number) => void;
onInteract?: () => void;
}) {
const surface = useRef<HTMLDivElement>(null);
const dragging = useRef<{ x: number; time: number; pointerId: number } | null>(null);
const snapping = useRef(false);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const [isSnapping, setIsSnapping] = useState(false);
const [animationWindow, setAnimationWindow] = useState<{ from: number; to: number } | null>(null);
const [width, setWidth] = useState(500);
const reduced = useReducedMotion();
const selectedValue = snapTimelineTime(value, start, end, step);
const selectedValue = following ? Math.max(start, Math.min(end, value)) : snapTimelineTime(value, start, end, step);
const position = useMotionValue(selectedValue);
const animation = useRef<{ stop: () => void } | null>(null);
const animationTarget = useRef<number | 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;
const isDragging = previewTime !== null && !isSnapping;
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
// Synchronize newly mounted ticks before paint when a monitoring step moves the dial.
useLayoutEffect(() => {
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();
if (snapping.current && animationTarget.current === selectedValue) return;
if (snapping.current) {
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
}
if (!following || reduced) {
position.set(selectedValue);
return;
}
const controls = animate(position, selectedValue, { duration: 0.12, ease: "linear" });
animation.current = controls;
return () => {
controls.stop();
if (animation.current === controls) animation.current = null;
};
}, [following, position, reduced, selectedValue]);
useEffect(() => () => {
if (previewFrame.current !== null) cancelAnimationFrame(previewFrame.current);
if (publishTimer.current !== null) clearTimeout(publishTimer.current);
dragging.current = null;
snapping.current = false;
animationTarget.current = null;
animation.current?.stop();
}, []);
useEffect(() => {
@@ -58,49 +84,109 @@ export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value,
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 renderStart = Math.min(displayTime, animationWindow?.from ?? displayTime, animationWindow?.to ?? displayTime);
const renderEnd = Math.max(displayTime, animationWindow?.from ?? displayTime, animationWindow?.to ?? displayTime);
const renderPadding = span / 2 + 2 * fine;
const markStart = Math.max(start, Math.floor((renderStart - renderPadding) / fine) * fine);
const markEnd = Math.min(end, Math.ceil((renderEnd + renderPadding) / fine) * fine);
const markCount = Math.max(1, Math.floor((markEnd - markStart) / fine) + 1);
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)
...Array.from({ length: markCount }, (_, index) => markStart + index * fine),
...[start, end].filter((time) => time >= markStart && time <= markEnd)
])]
.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]);
lensRadius={lensRadius} position={position} />), [end, fine, lensRadius, major, markCount, markEnd, markStart, 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 animateTo = (finalTime: number) => {
const interruptedTarget = animationTarget.current;
animation.current?.stop();
if (interruptedTarget !== null) position.set(interruptedTarget);
const animationFrom = position.get();
setPreviewTime(finalTime);
if (reduced || Math.abs(animationFrom - finalTime) < 1) {
position.set(finalTime);
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
animation.current = null;
animationTarget.current = null;
onChangeRef.current(finalTime);
return;
}
snapping.current = true;
animationTarget.current = finalTime;
setAnimationWindow({ from: animationFrom, to: finalTime });
setIsSnapping(true);
const controls = animate(position, finalTime, {
duration: SNAP_DURATION_SECONDS,
ease: SNAP_EASE,
onComplete: () => {
if (animation.current !== controls) return;
position.set(finalTime);
snapping.current = false;
setIsSnapping(false);
setPreviewTime(null);
setAnimationWindow(null);
animation.current = null;
animationTarget.current = null;
}
});
animation.current = controls;
onChangeRef.current(finalTime);
};
const moveImmediately = (finalTime: number) => {
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setAnimationWindow(null);
setPreviewTime(null);
position.set(finalTime);
onChangeRef.current(finalTime);
};
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);
animateTo(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 className="flex min-h-9 items-center justify-between gap-3 tabular-nums" aria-live="off">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{browsing || isDragging || isSnapping ? "时间定位" : following ? "播放时间" : "监测时间"}</span>
<span>{DATE_FORMAT.format(displayTime)}</span>
</div>
<span className="text-2xl font-semibold tracking-tight text-foreground">{timeFormat.format(displayTime)}</span>
</div>
<div ref={surface} role="slider" tabIndex={0} aria-label="SCADA 调频时间轴"
data-dragging={isDragging}
data-snapping={isSnapping}
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"
className="scada-fm-window relative h-28 touch-pan-y overflow-hidden rounded-xl outline-hidden"
onPointerDown={(event) => {
if (event.button !== 0) return;
onInteract?.();
event.stopPropagation();
if (dragging.current) return;
if (event.pointerType === "mouse") event.preventDefault();
animation.current?.stop();
animation.current = null;
animationTarget.current = null;
snapping.current = false;
setIsSnapping(false);
setAnimationWindow(null);
dragging.current = { x: event.clientX, time: position.get(), pointerId: event.pointerId };
setPreviewTime(snap(position.get()));
event.currentTarget.setPointerCapture(event.pointerId);
@@ -129,16 +215,16 @@ export const ScadaTimeTuner = memo(function ScadaTimeTuner({ start, end, value,
: key === "PageUp" ? moveTimelineTime(displayTime, start, end, step, major / step)
: key === "PageDown" ? moveTimelineTime(displayTime, start, end, step, -major / step) : null;
if (next !== null) {
onInteract?.();
event.preventDefault(); event.stopPropagation();
animation.current?.stop();
position.set(next);
onChange(next);
if (key.startsWith("Arrow")) animateTo(next);
else moveImmediately(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" />
<span aria-hidden="true" className="scada-fm-needle pointer-events-none absolute left-1/2 top-1.5 h-16 w-2.5 -translate-x-1/2" />
</div>
</div>
);
@@ -0,0 +1,85 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EventGpsCoordinate } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
const first: EventGpsCoordinate = [121.45, 30.93];
const second: EventGpsCoordinate = [121.46, 30.94];
function deferred() {
let resolve!: (address: string) => void;
const promise = new Promise<string>((done) => { resolve = done; });
return { promise, resolve };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("event location address lookup", () => {
it("debounces adjustments and prevents stale addresses from replacing a newer point", async () => {
const older = deferred();
const newer = deferred();
const resolveAddress = vi.fn<EventLocationAddressResolver>()
.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
const onChange = vi.fn();
const { result, rerender } = renderHook(useEventLocationAddress, {
initialProps: { coordinate: first, resolveAddress, onChange }
});
expect(result.current.pending).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(250));
const firstSignal = resolveAddress.mock.calls[0][1];
rerender({ coordinate: second, resolveAddress, onChange });
expect(firstSignal.aborted).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(250));
await act(async () => newer.resolve("新地址"));
await act(async () => older.resolve("旧地址"));
expect(onChange).toHaveBeenLastCalledWith("新地址");
expect(onChange).not.toHaveBeenCalledWith("旧地址");
expect(result.current.pending).toBe(false);
});
it("keeps a manual correction even if the address request finishes afterwards", async () => {
const request = deferred();
const resolveAddress = vi.fn<EventLocationAddressResolver>().mockReturnValue(request.promise);
const onChange = vi.fn();
const { result } = renderHook(() => useEventLocationAddress({ coordinate: first, resolveAddress, onChange }));
await act(() => vi.advanceTimersByTimeAsync(250));
act(() => result.current.changeLocation("现场确认地址"));
await act(async () => request.resolve("服务返回地址"));
expect(resolveAddress.mock.calls[0][1].aborted).toBe(true);
expect(onChange).toHaveBeenLastCalledWith("现场确认地址");
expect(result.current.pending).toBe(false);
});
it("cancels lookup when the editor closes and only queries the final keyboard adjustment", async () => {
const resolveAddress = vi.fn<EventLocationAddressResolver>().mockResolvedValue("地址");
const onChange = vi.fn();
const { result, rerender } = renderHook(useEventLocationAddress, {
initialProps: { active: true, coordinate: first, resolveAddress, onChange }
});
rerender({ active: true, coordinate: second, resolveAddress, onChange });
await act(() => vi.advanceTimersByTimeAsync(250));
expect(resolveAddress).toHaveBeenCalledOnce();
expect(resolveAddress.mock.calls[0][0]).toEqual(second);
rerender({ active: true, coordinate: first, resolveAddress, onChange });
rerender({ active: false, coordinate: first, resolveAddress, onChange });
await act(() => vi.advanceTimersByTimeAsync(250));
expect(resolveAddress).toHaveBeenCalledOnce();
expect(result.current.pending).toBe(false);
});
it("allows retry after a failed query without changing the selected GPS coordinate", async () => {
const resolveAddress = vi.fn<EventLocationAddressResolver>()
.mockRejectedValueOnce(new Error("地址服务暂不可用")).mockResolvedValueOnce("恢复后的地址");
const onChange = vi.fn();
const { result } = renderHook(() => useEventLocationAddress({ coordinate: first, resolveAddress, onChange }));
await act(() => vi.advanceTimersByTimeAsync(250));
expect(result.current.error).toBe("地址服务暂不可用");
expect(result.current.pending).toBe(false);
act(() => result.current.retry());
await act(() => vi.advanceTimersByTimeAsync(250));
expect(result.current.error).toBeNull();
expect(onChange).toHaveBeenLastCalledWith("恢复后的地址");
expect(resolveAddress.mock.calls[1][0]).toEqual(first);
});
});
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EventGpsCoordinate } from "./event-location-coordinate";
export type EventLocationAddressResolver = (coordinate: EventGpsCoordinate, signal: AbortSignal) => Promise<string>;
export type EventLocationAddressLookup = {
pending: boolean;
error: string | null;
changeLocation: (value: string) => void;
retry: () => void;
};
export function useEventLocationAddress({ active = true, coordinate, resolveAddress, onChange }: {
active?: boolean;
coordinate: EventGpsCoordinate | null;
resolveAddress: EventLocationAddressResolver;
onChange: (value: string) => void;
}): EventLocationAddressLookup {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const longitude = coordinate?.[0];
const latitude = coordinate?.[1];
useEffect(() => {
setError(null);
if (!active || longitude === undefined || latitude === undefined) {
setPending(false);
return;
}
const controller = new AbortController();
requestRef.current = controller;
setPending(true);
onChange("");
// Coalesce keyboard adjustments. GPS stays immediate while only the final
// point is reverse-geocoded; a former point's address must never be reused.
const timer = window.setTimeout(async () => {
if (controller.signal.aborted) return;
try {
const address = await resolveAddress([longitude, latitude], controller.signal);
if (!controller.signal.aborted) onChange(address);
} catch (failure) {
if (!controller.signal.aborted) {
setError(failure instanceof Error ? failure.message : "地址查询失败,请重试或手动填写");
}
} finally {
if (!controller.signal.aborted) setPending(false);
}
}, 250);
return () => {
window.clearTimeout(timer);
controller.abort();
if (requestRef.current === controller) requestRef.current = null;
};
}, [active, longitude, latitude, resolveAddress, onChange, retryGeneration]);
const changeLocation = useCallback((value: string) => {
requestRef.current?.abort();
setPending(false);
setError(null);
onChange(value);
}, [onChange]);
return { pending, error, changeLocation, retry: () => setRetryGeneration((current) => current + 1) };
}
@@ -0,0 +1,144 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ScadaReplay } from "../api/scada-replay-client";
import { useScadaPlayback } from "./use-scada-playback";
const start = Date.parse("2026-07-16T07:30:00+08:00");
const end = start + 3600_000;
function receipt(time: number, status = "paused") {
return { virtual_now: new Date(time).toISOString(), status, step_pending: false } as ScadaReplay;
}
function setup(advance = vi.fn<() => Promise<ScadaReplay | null>>()) {
const options = { active: true, enabled: true, busy: false, start, end, now: start,
intervalSeconds: 900, restart: false, advance };
return { ...renderHook((props) => useScadaPlayback(props), { initialProps: options }), options, advance };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("advances the display first and monitors once at each project boundary until the end", async () => {
let count = 0;
const advance = vi.fn(async () => {
count += 1;
return receipt(start + count * 900_000, count === 4 ? "completed" : "paused");
});
const { result } = setup(advance);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(result.current.time).toBe(start + 450_000);
expect(advance).not.toHaveBeenCalled();
await act(() => vi.advanceTimersByTimeAsync(17500));
expect(advance).toHaveBeenCalledTimes(4);
expect(result.current.time).toBe(end);
expect(result.current.playing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).toHaveBeenCalledTimes(4);
});
it("waits for a slow diagnosis without overlapping requests or jumping ahead", async () => {
let resolve!: (value: ScadaReplay) => void;
const advance = vi.fn(() => new Promise<ScadaReplay>((done) => { resolve = done; }));
const { result } = setup(advance);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(20000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.time).toBe(start + 900_000);
act(() => result.current.pause());
await act(async () => resolve(receipt(start + 900_000)));
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.playing).toBe(false);
});
it("pauses when the panel becomes inactive and resumes from the same display time", async () => {
const { result, rerender, options, advance } = setup();
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.newRound).toBe(false);
const pausedAt = result.current.time;
rerender({ ...options, active: false });
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(result.current.time).toBe(pausedAt);
expect(result.current.playing).toBe(false);
expect(advance).not.toHaveBeenCalled();
rerender(options);
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(pausedAt + 180_000);
});
it("pauses on a failed command and does not retry automatically", async () => {
const { result, advance } = setup(vi.fn(async () => null));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(15000));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.playing).toBe(false);
});
it("uses the configured monitoring interval independently of the dial's five-minute ticks", async () => {
const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 300_000)));
rerender({ ...options, intervalSeconds: 300 });
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(1700));
expect(advance).toHaveBeenCalledTimes(1);
expect(result.current.time).toBe(start + 300_000);
});
it("pauses for a hidden page and cleans up its automatic monitor on unmount", async () => {
const { result, unmount, advance } = setup();
act(() => result.current.play());
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
act(() => document.dispatchEvent(new Event("visibilitychange")));
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(result.current.playing).toBe(false);
visibility.mockRestore();
act(() => result.current.play());
unmount();
await vi.advanceTimersByTimeAsync(10000);
expect(advance).not.toHaveBeenCalled();
});
it("resumes a restarted round before its first request without resetting the clock again", async () => {
const { result, rerender, options, advance } = setup();
rerender({ ...options, now: end, restart: true });
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.newRound).toBe(true);
const pausedAt = result.current.time;
act(() => result.current.pause());
act(() => result.current.play());
expect(result.current.time).toBe(pausedAt);
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(pausedAt + 180_000);
expect(advance).not.toHaveBeenCalled();
});
it("keeps playing when the refreshed snapshot arrives before the command promise resolves", async () => {
let resolve!: (value: ScadaReplay) => void;
const { result, rerender, options } = setup(vi.fn(() => new Promise<ScadaReplay>((done) => { resolve = done; })));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(5000));
rerender({ ...options, now: start + 900_000 });
await act(async () => resolve(receipt(start + 900_000)));
expect(result.current.playing).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(result.current.time).toBe(start + 1080_000);
});
it("resets to a paused beginning without processing until play is pressed again", async () => {
const { result, rerender, options, advance } = setup(vi.fn(async () => receipt(start + 900_000)));
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(result.current.time).toBe(start + 360_000);
act(() => result.current.reset());
expect(result.current.time).toBe(start);
expect(result.current.playing).toBe(false);
expect(result.current.started).toBe(false);
expect(result.current.newRound).toBe(false);
rerender(options);
await act(() => vi.advanceTimersByTimeAsync(10000));
expect(advance).not.toHaveBeenCalled();
act(() => result.current.play());
await act(() => vi.advanceTimersByTimeAsync(5000));
expect(advance).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import type { ScadaReplay } from "../api/scada-replay-client";
export const SCADA_PLAYBACK_SPEED = 180;
interface PlaybackOptions {
active: boolean;
enabled: boolean;
busy: boolean;
start: number;
end: number;
now: number;
intervalSeconds: number | null | undefined;
restart: boolean;
advance: () => Promise<ScadaReplay | null>;
}
/** Browser-owned demo clock. Each boundary waits for one durable Server step. */
export function useScadaPlayback(options: PlaybackOptions) {
const latest = useRef(options);
latest.current = options;
const [playing, setPlaying] = useState(false);
const [started, setStarted] = useState(false);
const [newRound, setNewRound] = useState(false);
const [time, setTime] = useState(options.now);
const running = useRef(false);
const pending = useRef(false);
const position = useRef(options.now);
const processed = useRef(options.now);
const mounted = useRef(true);
const restartPrepared = useRef(false);
const pause = () => { running.current = false; setPlaying(false); };
const reset = () => {
pause();
setStarted(false);
setNewRound(false);
restartPrepared.current = false;
processed.current = position.current = latest.current.start;
setTime(latest.current.start);
};
useEffect(() => {
mounted.current = true;
const hidden = () => { if (document.visibilityState !== "visible") pause(); };
document.addEventListener("visibilitychange", hidden);
return () => {
mounted.current = false;
running.current = false;
document.removeEventListener("visibilitychange", hidden);
};
}, []);
useEffect(() => {
pause();
setStarted(false);
setNewRound(false);
position.current = latest.current.now;
processed.current = latest.current.now;
setTime(latest.current.now);
}, [options.start, options.end]);
useEffect(() => {
processed.current = options.now;
position.current = options.now;
setTime(options.now);
if (options.now >= latest.current.end) pause();
}, [options.now]);
useEffect(() => {
if (!options.active || !options.enabled || options.restart) pause();
}, [options.active, options.enabled, options.restart]);
useEffect(() => { restartPrepared.current = false; }, [options.restart]);
useEffect(() => {
if (!playing) return;
let previousTick = performance.now();
const timer = window.setInterval(() => {
const current = latest.current;
const tick = performance.now();
const elapsed = Math.min(tick - previousTick, 250);
previousTick = tick;
if (!running.current || !current.active || !current.enabled || document.visibilityState !== "visible"
|| pending.current || current.busy || !current.intervalSeconds) return;
const interval = current.intervalSeconds * 1000;
const target = Math.min(current.end, current.start
+ (Math.floor((processed.current - current.start) / interval) + 1) * interval);
position.current = Math.min(target, position.current + elapsed * SCADA_PLAYBACK_SPEED);
setTime(position.current);
if (position.current < target) return;
pending.current = true;
const requestedFrom = processed.current;
void current.advance().then((next) => {
if (!mounted.current) return;
if (!next || next.status === "failed" || next.step_pending) {
pause();
return;
}
const nextTime = Date.parse(next.virtual_now);
if (nextTime <= requestedFrom && !current.restart) {
pause();
return;
}
processed.current = position.current = nextTime;
setNewRound(false);
setTime(nextTime);
if (next.status === "completed" || nextTime >= current.end) pause();
}).catch(() => { if (mounted.current) pause(); }).finally(() => {
pending.current = false;
previousTick = performance.now();
});
}, 100);
return () => window.clearInterval(timer);
}, [playing]);
const play = () => {
if (!options.active || !options.enabled || options.busy || pending.current || !options.intervalSeconds) return;
if ((options.restart && !restartPrepared.current) || position.current >= options.end) {
processed.current = position.current = options.start;
restartPrepared.current = true;
setNewRound(true);
setTime(options.start);
}
running.current = true;
setStarted(true);
setPlaying(true);
};
return { playing, started, newRound, time, play, pause, reset };
}
@@ -50,21 +50,25 @@ export function useScadaReplay(active: boolean, access?: WorkbenchAccess) {
}, [active, refresh]);
const change = async (operation: () => Promise<ScadaReplay>) => {
if (mutation.current) return;
if (mutation.current) return null;
mutation.current = true;
++refreshSequence.current;
setBusy(true); setError(null);
try {
const next = await operation();
setReplay((previous) => reuseReplayProjection(previous, next));
return next;
}
catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "回放操作失败");
return null;
}
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();
if (!replay) return Promise.resolve(null);
return change(() => client.command(replay, action));
}
};
+1
View File
@@ -24,6 +24,7 @@ export type WorkbenchScenario = {
};
export type WorkbenchAlert = {
notificationVersion?: number;
conditionId?: string;
id: string;
description: string;
@@ -2,14 +2,12 @@ import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
const center: [number, number] = [121.48, 31.1];
const target = { source_id: "scada", feature_id: "P1" } as const;
describe("switchable investigation Dev cases", () => {
it.each(investigationDevCases.filter((item) => !item.id.startsWith("invalid_")))(
"$label produces a valid, nonempty batch within the existing presentation contract",
({ id }) => {
const input = createInvestigationDevCase(id, center, target);
const input = createInvestigationDevCase(id);
const preview = parseInvestigationDevJsonl(input);
const records = input.split("\n").map((line) => JSON.parse(line));
expect(records.every((record) => typeof record.record_type === "string")).toBe(true);
@@ -28,50 +26,57 @@ describe("switchable investigation Dev cases", () => {
it.each(investigationDevCases.filter((item) => item.id.startsWith("invalid_")))(
"$label cannot produce an executable batch",
({ id }) => {
expect(() => parseInvestigationDevJsonl(createInvestigationDevCase(id, center))).toThrow(
expect(() => parseInvestigationDevJsonl(createInvestigationDevCase(id))).toThrow(
id === "invalid_json" ? "第 2 行不是合法 JSON" : "第 1 行展示指令不合法"
);
}
);
it("only queries an existing task target and falls back to a synthetic map point without one", () => {
const withTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center, target)
);
expect(withTarget.commands[0]).toMatchObject({
action: "highlight_features",
layer: "scada",
feature_ids: ["P1"]
});
const withoutTarget = parseInvestigationDevJsonl(
createInvestigationDevCase("map_targets", center)
);
expect(withoutTarget.commands).toHaveLength(1);
expect(withoutTarget.commands[0]).toMatchObject({ action: "set_overlay", mode: "geometry" });
it("keeps feature locating and highlighting in map debugging rather than running another path from a synthetic case", () => {
expect(investigationDevCases.some((item) => String(item.id) === "map_targets")).toBe(false);
for (const item of investigationDevCases.filter((item) => !item.id.startsWith("invalid_"))) {
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(item.id));
expect(preview.commands.some((command) => ["locate_features", "highlight_features"].includes(command.action))).toBe(false);
}
});
it("provides dense tables and full-day series without exceeding publication limits", () => {
const tables = parseInvestigationDevJsonl(createInvestigationDevCase("complex_tables"));
const nodes = tables.blocks.map((block) => Object.values(block.spec.elements)[0]);
expect(nodes[0].props.columns).toHaveLength(8);
expect(nodes[0].props.rows).toHaveLength(50);
expect(nodes[1].props.rows).toEqual([]);
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props;
expect(pressure.x_data).toHaveLength(96);
expect(pressure.series).toHaveLength(8);
});
it.each(["data_only", "candidates_table", "valves_table"] as const)(
"%s never turns synthetic asset IDs or embedded action fields into map queries or writes",
(id) => {
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(id, center));
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(id));
expect(preview.commands).toEqual([]);
expect(preview.records).toHaveLength(2);
expect(preview.records.every((record) => record.origin === "synthetic")).toBe(true);
}
);
it.each([
[180, 90],
[-180, -90],
[540, 85]
] as Array<[number, number]>)(
"keeps synthetic geometry within schema bounds near longitude %s, latitude %s",
(longitude, latitude) => {
for (const id of ["combined", "map_targets", "impact_area", "complaint_heatmap"] as const) {
expect(() =>
parseInvestigationDevJsonl(createInvestigationDevCase(id, [longitude, latitude]))
).not.toThrow();
it("keeps every spatial fixture inside the verified tjwater_v2 network bounds", () => {
const visit = (value: unknown) => {
if (!Array.isArray(value)) return;
if (typeof value[0] === "number" && typeof value[1] === "number") {
expect(value[0]).toBeGreaterThanOrEqual(121.3516324392188);
expect(value[0]).toBeLessThanOrEqual(121.77248154882845);
expect(value[1]).toBeGreaterThanOrEqual(30.810500094449143);
expect(value[1]).toBeLessThanOrEqual(31.00720694600021);
} else value.forEach(visit);
};
for (const id of ["combined", "complex_investigation", "impact_area", "complaint_heatmap"] as const) {
const preview = parseInvestigationDevJsonl(createInvestigationDevCase(id));
for (const command of preview.commands) if (command.action === "set_overlay") {
for (const feature of command.data.features) visit(feature.geometry.coordinates);
}
}
);
});
});
@@ -1,11 +1,11 @@
import type { AnswerUiBlock } from "@/features/agent";
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import { INVESTIGATION_DEV_NETWORK } from "./investigation-dev-network";
import { complexInvestigationDevCases, createComplexInvestigationDevCase } from "./investigation-dev-complex-cases";
import {
createInvestigationDevSample,
createPresentationRecord,
createUiRecord,
normalizeDevCenter,
serializeInvestigationDevJsonl
} from "./investigation-dev-preview";
@@ -13,13 +13,9 @@ export const investigationDevCases = [
{
id: "combined",
label: "组合联动",
expected: "合成空间点、压力曲线、候选表格与数据一同展示;有调查地图目标时同时高亮。"
},
{
id: "map_targets",
label: "地图定位与高亮",
expected: "显示地图中心附近的合成点,并高亮所选调查已有的地图目标;无目标时仅显示合成点。"
expected: "合成空间点、压力曲线与候选表格一起展示;要素定位和高亮请使用地图调试页签。"
},
...complexInvestigationDevCases,
{
id: "pressure_line",
label: "压力趋势对比",
@@ -101,14 +97,12 @@ function table(id: string, title: string, columns: string[], rows: string[][]):
return createUiRecord(block);
}
/** Fixtures use current map coordinates and existing task targets, never invented query IDs. */
/** Synthetic fixtures use current map coordinates and never query invented asset IDs. */
export function createInvestigationDevCase(
caseId: InvestigationDevCaseId,
center: [number, number],
target?: InvestigationMapTarget
caseId: InvestigationDevCaseId
): string {
const [longitude, latitude] = normalizeDevCenter(center);
if (caseId === "combined") return createInvestigationDevSample([longitude, latitude], target);
const [longitude, latitude] = INVESTIGATION_DEV_NETWORK.center;
if (caseId === "combined") return createInvestigationDevSample();
const point = (dx = 0, dy = 0): [number, number] => [
Math.max(-180, Math.min(180, longitude + dx)),
latitude + dy
@@ -131,26 +125,10 @@ export function createInvestigationDevCase(
});
switch (caseId) {
case "map_targets":
return jsonl([
...(target
? [
presentation({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
]
: []),
overlay("geometry", "本地合成定位点", [
{
type: "Feature",
properties: { origin: "synthetic" },
geometry: { type: "Point", coordinates: point() }
}
])
]);
case "complex_investigation":
case "complex_tables":
case "complex_charts":
return createComplexInvestigationDevCase(caseId, [longitude, latitude]);
case "pressure_line":
return jsonl([
presentation({
@@ -187,7 +165,7 @@ export function createInvestigationDevCase(
overlay("geometry", "本地合成影响范围", [
{
type: "Feature",
properties: { origin: "synthetic", purpose: "display_test_only" },
properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", purpose: "display_test_only" },
geometry: {
type: "Polygon",
coordinates: [
@@ -210,7 +188,7 @@ export function createInvestigationDevCase(
"本地合成投诉热力",
[-0.0015, 0, 0.0015].map((offset, index) => ({
type: "Feature",
properties: { origin: "synthetic", weight: [1, 3, 2][index] },
properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", weight: [1, 3, 2][index] },
geometry: { type: "Point", coordinates: point(offset, offset / 2) }
}))
)
@@ -222,12 +200,12 @@ export function createInvestigationDevCase(
"合成候选管段对比",
["候选", "优先级", "核查依据"],
[
["DEMO-PIPE-01", "1", "合成压力降幅较大,待核查"],
["DEMO-PIPE-02", "2", "合成相关性较弱,待排除"]
[INVESTIGATION_DEV_NETWORK.pipeIds[0], "1", "合成压力降幅较大,待核查"],
[INVESTIGATION_DEV_NETWORK.pipeIds[1], "2", "合成相关性较弱,待排除"]
]
),
data("candidate", { element_id: "DEMO-PIPE-01", rank: 1, status: "unverified" }),
data("candidate", { element_id: "DEMO-PIPE-02", rank: 2, status: "unverified" })
data("candidate", { element_id: INVESTIGATION_DEV_NETWORK.pipeIds[0], rank: 1, status: "unverified" }),
data("candidate", { element_id: INVESTIGATION_DEV_NETWORK.pipeIds[1], rank: 2, status: "unverified" })
]);
case "valves_table":
return jsonl([
@@ -236,17 +214,17 @@ export function createInvestigationDevCase(
"合成隔离阀门清单",
["阀门", "操作建议", "核查要求"],
[
["DEMO-VALVE-01", "待评估", "位置与可操作性未核实"],
["DEMO-VALVE-02", "待评估", "连通性与影响范围未核实"]
[INVESTIGATION_DEV_NETWORK.valveIds[0], "待评估", "位置与可操作性未核实"],
[INVESTIGATION_DEV_NETWORK.valveIds[1], "待评估", "连通性与影响范围未核实"]
]
),
data("valve", { element_id: "DEMO-VALVE-01", status: "unverified", executed: false }),
data("valve", { element_id: "DEMO-VALVE-02", status: "unverified", executed: false })
data("valve", { element_id: INVESTIGATION_DEV_NETWORK.valveIds[0], status: "unverified", executed: false }),
data("valve", { element_id: INVESTIGATION_DEV_NETWORK.valveIds[1], status: "unverified", executed: false })
]);
case "data_only":
return jsonl([
data("candidate", {
element_id: "DEMO-PIPE-03",
element_id: INVESTIGATION_DEV_NETWORK.pipeIds[2],
action: "clear_highlights",
status: "data_only"
}),
@@ -0,0 +1,78 @@
import { INVESTIGATION_DEV_NETWORK } from "./investigation-dev-network";
import type { AnswerUiBlock } from "@/features/agent";
import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview";
export const complexInvestigationDevCases = [
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "3 张图、2 张表与合成覆盖面;可同时放大对比,单位分别展示,不查询真实要素。" },
{ id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" },
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "96 个时间点、8 条压力序列,另有流量与方案柱状图;可切换数据表、筛选区间并独立放大。" }
] as const;
type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"];
type Node = AnswerUiBlock["spec"]["elements"][string];
const ui = (id: string, node: Node) => createUiRecord({
version: "answer-ui@1", block_id: `dev-complex-${id}`, phase: "final",
fallback_text: "本地合成展示数据,不代表实际监测、调查结论或现场方案。",
spec: { root: "result", elements: { result: node } }
});
const table = (id: string, title: string, columns: string[], rows: string[][]) =>
ui(id, { type: "Table", props: { title, columns, rows }, children: [] });
const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const pressure = {
title: "合成全天压力对比", chart_type: "line" as const, x_data: times,
series: Array.from({ length: 8 }, (_, series) => ({
name: `合成节点 ${INVESTIGATION_DEV_NETWORK.nodeAnchors[series].id} · ${series < 4 ? "主干管" : "末端支线"}压力水头(m)`,
data: times.map((_, index) => Number((34 + series * 0.6 + Math.sin(index / 7 + series) * 2.4 - (index >= 32 && index < 44 ? 7 - series * 0.4 : 0)).toFixed(2)))
}))
};
const flow = {
title: "合成分区流量趋势", chart_type: "line" as const, x_data: times,
series: Array.from({ length: 4 }, (_, series) => ({ name: `合成分区 ${series + 1} 流量(m³/h)`,
data: times.map((_, index) => Number((120 + series * 28 + Math.sin(index / 9 + series) * 24 + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))) }))
};
const comparison = {
title: "合成候选方案影响对比", chart_type: "bar" as const,
x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"],
series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }]
};
const chart = (id: string, value: typeof pressure | typeof flow | typeof comparison) =>
ui(id, { type: "Chart", props: { ...value, source: "本地确定性合成数据,仅供展示测试;曲线变化和方案名称均非实际分析结果。" }, children: [] });
const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"];
const rows = Array.from({ length: 50 }, (_, index) => [
String(index + 1), INVESTIGATION_DEV_NETWORK.pipeIds[index],
`合成片区 ${index % 5 + 1} · ${index % 2 ? "学校与居民区交界" : "工业园区及长距离末端支线"}`,
String(80 + index * 13.5), (index % 7 === 0 ? 0 : -(index % 13) / 2).toFixed(1),
String(index % 6 === 0 ? 0 : (index * 37) % 600),
["待现场核验", "资料待补充", "合成候选,未确认"][index % 3],
index % 5 === 0
? `仅供本地测试:样例记录 ${String(index + 1).padStart(3, "0")} 包含较长的核查说明,用于检查换行、滚动、筛选及放大后阅读。请分别核对供水边界、阀门状态与现场回执。合成引用 SYNTHETIC_REFERENCE_${"LONG_SEGMENT_".repeat(8)}${index + 1},不是真实证据。`
: `仅为合成候选资料,样例记录 ${String(index + 1).padStart(3, "0")};不代表已确认故障或已执行操作。`
]);
export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string {
if (caseId === "complex_charts") return serializeInvestigationDevJsonl([
chart("pressure", pressure), chart("flow", flow), chart("comparison", comparison)
]);
if (caseId === "complex_tables") return serializeInvestigationDevJsonl([
table("wide-table", "合成候选核查宽表", columns, rows),
table("empty-table", "合成空结果表", ["对象", "核查结果", "说明"], []),
table("edge-table", "合成排序与缺口对照", ["对象", "变化量", "完成率", "说明"], [
["合成对象 A", "0", "0%", "零值不是缺失"], ["合成对象 B", "-2.5", "50%", "负数与百分比排序"],
["合成对象 C", "10", "100%", "按数值排列,不按文本排列"], ["合成对象 D", "2", "50%", "重复值保持可读"],
["合成对象 E", "2", "50%", "重复值第二行"], ["合成对象 F", "未提供", "未提供", "缺失不替换为零"]
])
]);
const point = (dx: number, dy: number): [number, number] => [Math.max(-180, Math.min(180, longitude + dx)), Math.max(-89.99, Math.min(89.99, latitude + dy))];
return serializeInvestigationDevJsonl([
createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic",
data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` },
geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }),
chart("pressure", pressure), chart("flow", flow),
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }),
table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)),
table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"],
INVESTIGATION_DEV_NETWORK.valveIds.map((id, index) => [id, `合成片区 ${index % 3 + 1}`, "合成居民区与主干管交界处,位置尚未现场核验", "核对可操作性、实际开度及现场回执;不执行关阀", "待核查"]))
]);
}
@@ -0,0 +1,130 @@
/** tjwater_v2 GIS IDs and WGS84 anchors verified read-only on 2026-11-02.
* Test readings, comparisons and derived coverage remain synthetic. */
export const INVESTIGATION_DEV_NETWORK = {
"projectCode": "tjwater_v2",
"projectId": "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
"bounds4326": [
121.3516324392188,
30.810500094449143,
121.77248154882845,
31.00720694600021
],
"center": [
121.503776204,
30.922428855
],
"pipeIds": [
"441237",
"460186",
"538937",
"512832",
"544604",
"497587",
"544646",
"544647",
"479907",
"473213",
"482522",
"523253",
"547098",
"536422",
"491909",
"490672",
"512511",
"530710",
"521589",
"499189",
"400481",
"547097",
"540603",
"536252",
"536018",
"465147",
"540768",
"514748",
"515961",
"536524",
"533097",
"531617",
"472558",
"400010",
"472650",
"456467",
"459465",
"533252",
"504570",
"516165",
"532583",
"408637",
"523294",
"534255",
"535351",
"540963",
"532312",
"531177",
"481121",
"540693"
],
"nodeAnchors": [
{
"id": "551711-A",
"coordinates": [
121.503776204,
30.922428855
]
},
{
"id": "460188",
"coordinates": [
121.503742515,
30.922425166
]
},
{
"id": "123271",
"coordinates": [
121.503811776,
30.922432272
]
},
{
"id": "544603",
"coordinates": [
121.503803429,
30.922490451
]
},
{
"id": "123261",
"coordinates": [
121.503878525,
30.922433606
]
},
{
"id": "544645",
"coordinates": [
121.503895161,
30.922436488
]
},
{
"id": "123262",
"coordinates": [
121.503953648,
30.922447024
]
},
{
"id": "123264",
"coordinates": [
121.503992465,
30.922454319
]
}
],
"valveIds": [
"551711",
"551714"
]
} as const;
@@ -9,10 +9,9 @@ import type { OperationalTask } from "../api/operational-task-client";
describe("investigation Dev JSONL", () => {
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
const result = parseInvestigationDevJsonl(
createInvestigationDevSample([121.48, 31.1], { source_id: "scada", feature_id: "P1" })
createInvestigationDevSample()
);
expect(result.commands.map((item) => item.action)).toEqual([
"highlight_features",
"set_overlay",
"open_chart"
]);
@@ -84,7 +83,7 @@ describe("investigation Dev JSONL", () => {
JSON.stringify({ record_type: "ui_block", block: { version: "answer-ui@1" } })
)
).toThrow("界面块不合法");
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
const block = parseInvestigationDevJsonl(createInvestigationDevSample()).blocks[0];
expect(() =>
parseInvestigationDevJsonl(
[block, block]
@@ -99,12 +98,12 @@ describe("investigation Dev JSONL", () => {
});
it("rejects bare UI blocks instead of guessing a record type", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
const block = parseInvestigationDevJsonl(createInvestigationDevSample()).blocks[0];
expect(() => parseInvestigationDevJsonl(JSON.stringify(block))).toThrow("第 1 行");
});
it("counts only registered valid report components", () => {
const block = parseInvestigationDevJsonl(createInvestigationDevSample([121, 31])).blocks[0];
const block = parseInvestigationDevJsonl(createInvestigationDevSample()).blocks[0];
const task = {
latest_report: {
report: {
@@ -5,7 +5,7 @@ import {
type AnswerUiBlock
} from "@/features/agent";
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
import type { InvestigationMapTarget } from "../api/investigation-report";
import { INVESTIGATION_DEV_NETWORK } from "./investigation-dev-network";
import type { OperationalTask } from "../api/operational-task-client";
export type InvestigationDevPreview = {
@@ -150,16 +150,8 @@ export function investigationReportUiSummary(task: OperationalTask | null) {
return counts;
}
export function normalizeDevCenter([longitude, latitude]: [number, number]): [number, number] {
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("地图中心坐标必须为有限数值。");
return [((((longitude + 180) % 360) + 360) % 360) - 180, Math.max(-89.99, Math.min(89.99, latitude))];
}
export function createInvestigationDevSample(
center: [number, number],
target?: InvestigationMapTarget
): string {
center = normalizeDevCenter(center);
export function createInvestigationDevSample(): string {
const center: [number, number] = [...INVESTIGATION_DEV_NETWORK.center];
const chart = {
title: "本地合成压力趋势",
chart_type: "line" as const,
@@ -189,8 +181,8 @@ export function createInvestigationDevSample(
title: "合成候选对比",
columns: ["候选", "排序", "说明"],
rows: [
["DEMO-PIPE-01", "1", "仅测试表格展示"],
["DEMO-PIPE-02", "2", "未执行定位分析"]
[INVESTIGATION_DEV_NETWORK.pipeIds[0], "1", "仅测试表格展示"],
[INVESTIGATION_DEV_NETWORK.pipeIds[1], "2", "未执行定位分析"]
]
},
children: []
@@ -199,15 +191,6 @@ export function createInvestigationDevSample(
}
};
const records: unknown[] = [];
if (target)
records.push(
createPresentationRecord({
action: "highlight_features",
layer: target.source_id,
feature_ids: [target.feature_id],
effect: "pulse"
})
);
records.push(
createPresentationRecord({
action: "set_overlay",
@@ -220,7 +203,7 @@ export function createInvestigationDevSample(
features: [
{
type: "Feature",
properties: { origin: "synthetic" },
properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic" },
geometry: { type: "Point", coordinates: center }
}
]
@@ -237,7 +220,7 @@ export function createInvestigationDevSample(
record_type: "candidate",
origin: "synthetic",
source_evidence_refs: [],
element_id: "DEMO-PIPE-01",
element_id: INVESTIGATION_DEV_NETWORK.pipeIds[0],
rank: 1,
status: "example_only"
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { presentationEventKey } from "../api/investigation-presentation";
import type { InvestigationPresentationHistoryEvent } from "../api/investigation-presentation";
import type { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import { presentationHistoryPorts } from "../map/presentation-history-ports";
@@ -72,6 +73,52 @@ function setup() {
}
describe("saved investigation presentation", () => {
it.each(["cancelled", "error", "running"] as const)("rebuilds a %s event in place on reconnect", async (status) => {
const test = setup();
const id = test.history.begin("未完成图表", "live", task.title, {
key: presentationEventKey(task.task_id, 1), taskId: task.task_id,
});
if (status !== "running") test.history.finish(id, status, "中断");
const restored = await test.load([event(1, chart("恢复后的压力"))]);
expect(restored.charts.map((item) => item.chart.title)).toEqual(["恢复后的压力"]);
expect(test.history.entries).toHaveLength(1);
expect(test.history.entries[0]).toMatchObject({ id, status: "completed", message: undefined });
});
it("retries failed geometry without letting a later cumulative cache erase its recovery", async () => {
const test = setup();
test.controller.queryPresentationFeatures.mockRejectedValueOnce(new Error("offline"));
const events = [event(1, { action: "locate_features", layer: "scada", feature_ids: ["P1"] }),
event(2, chart("后续曲线"))];
await test.load(events);
expect(test.history.entries.map((entry) => entry.status)).toEqual(["error", "completed"]);
const originalIds = test.history.entries.map((entry) => entry.id);
const restored = await test.load(events);
expect(restored.highlights.features).toHaveLength(1);
expect(restored.charts[0].chart.title).toBe("后续曲线");
expect(test.history.entries.map((entry) => entry.id)).toEqual(originalIds);
expect(test.history.entries.map((entry) => entry.status)).toEqual(["completed", "completed"]);
expect(test.history.entries[1].snapshot.highlights.features).toHaveLength(1);
expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(2);
await test.load(events);
expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(2);
});
it("reuses completed geometry without overwriting a recovered earlier chart", async () => {
const test = setup();
const id = test.history.begin("已取消曲线", "live", task.title, {
key: presentationEventKey(task.task_id, 1), taskId: task.task_id,
});
test.history.finish(id, "cancelled");
const later = event(2, { action: "locate_features", layer: "scada", feature_ids: ["P1"] });
await test.load([later]);
const restored = await test.load([event(1, chart("恢复后的压力")), later]);
expect(restored.charts[0].chart.title).toBe("恢复后的压力");
expect(restored.highlights.features).toHaveLength(1);
expect(test.controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
expect(test.history.entries).toHaveLength(2);
expect(test.history.entries[1].snapshot.charts[0].chart.title).toBe("恢复后的压力");
});
it("hydrates sequential snapshots, applies cleanup without history rows and deduplicates task reselection", async () => {
const test = setup();
const events = [
@@ -44,10 +44,6 @@ export async function hydrateInvestigationPresentationArchive({
}
const key = presentationEventKey(task.task_id, event.event_seq);
const cached = history.entries.find((entry) => entry.key === key);
if (cached) {
staging.update(cached.snapshot);
continue;
}
const parsed = investigationPresentationEventSchema.safeParse({
task_id: event.task_id,
run_id: event.run_id,
@@ -57,7 +53,19 @@ export async function hydrateInvestigationPresentationArchive({
let message: string | undefined;
try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
await executePresentationCommand(parsed.data.command, signal, ports);
const command = parsed.data.command;
// Completed map queries can reuse their own geometry. Their accumulated
// snapshot may predate a recovered earlier event, so never replace staging
// wholesale with a later cached view.
const verified = cached?.status === "completed" ? cached.snapshot : null;
if (verified && (command.action === "highlight_features" || command.action === "locate_features")) {
staging.update({ ...staging.current, highlights: verified.highlights,
highlightLayers: verified.highlightLayers, camera: verified.camera });
} else if (verified && command.action === "render_scada_analysis") {
staging.update({ ...staging.current, scada: verified.scada, camera: verified.camera });
} else {
await executePresentationCommand(command, signal, ports);
}
} catch (cause) {
signal.throwIfAborted();
message = cause instanceof Error ? cause.message : "历史展示暂不可用";
@@ -81,21 +89,19 @@ export async function hydrateInvestigationPresentationArchive({
const key = `task:${task.task_id}:report:${report.revision}`;
const cached = history.entries.find((entry) => entry.key === key);
const blocks =
cached?.snapshot.blocks ??
(cached?.status === "completed" ? cached.snapshot.blocks : null) ??
(report.report.ui_blocks ?? []).flatMap((item) => {
const block = parseAnswerUiBlock(item.block);
return block ? [block] : [];
});
if (blocks.length) {
current = { ...current, blocks };
if (!cached) {
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
}
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
}
}
return current;
@@ -92,6 +92,11 @@ export function investigationStatus(task: OperationalTask): string {
task.lifecycle_status === "completed"
)
return "已结束 · 证据不足";
const recoveryPhase = task.handling_context?.phase;
if (recoveryPhase === "waiting_work_order") return "等待现场工单返回";
if (recoveryPhase === "waiting_scada") return "恢复供水和监测复核";
if (recoveryPhase === "verification_ready") return "等待恢复复核报告";
if (recoveryPhase === "completed" && task.lifecycle_status === "completed") return "复核成功 · 已完成";
if (task.lifecycle_status === "completed") return "本轮调查完成";
return "调查中";
}
@@ -2,6 +2,12 @@ import { describe, expect, it } from "vitest";
import { formatInvestigationText } from "./investigation-text";
describe("investigation prose time display", () => {
it("converts UTC and UTC0 prose while treating unzoned reading times as UTC8", () => {
expect(formatInvestigationText("目标2026-07-16 00:00(UTC0),对照23:45–00:00 UTC,显示08:00(UTC+8)。"))
.toBe("目标2026/7/16 08:00:00(UTC+8),对照07:45–08:00(UTC+8),显示08:00(UTC+8)。");
expect(formatInvestigationText("读数00:00 UTC;恢复00:15Z;2026-07-16 08:00"))
.toBe("读数08:00(UTC+8);恢复08:15(UTC+8);2026-07-16 08:00");
});
it("converts explicit instants and UTC windows from historical reports", () => {
expect(formatInvestigationText("上报于2026-10-08T04:00:00Z;查询2026-10-08 04:00–05:00Z。"))
.toBe("上报于2026/10/8 12:00:00(UTC+8);查询2026/10/8 12:00–13:00(UTC+8)。");
@@ -1,37 +1 @@
import { formatDateTime, formatTime } from "@/shared/utils/date-time";
const zonedTime = /(?<![\w/+:.-])(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)(Z|[+-]\d{2}:\d{2})(?![\w:+/-])/g;
const utcRange = /(?<![\w/+:.-])(?:(\d{4}-\d{2}-\d{2})[T ])?(\d{2}:\d{2})(?:\s*[–—~至-]\s*)(\d{2}:\d{2})Z(?![\w:+/-])/g;
function parseTime(day: string, clock: string, zone = "Z"): number | null {
const date = new Date(`${day}T00:00:00Z`);
const [hours, minutes, seconds = "0"] = clock.split(":");
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== day
|| Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) >= 60) return null;
const value = Date.parse(`${day}T${clock}${zone}`);
return Number.isFinite(value) ? value : null;
}
/** Display historical report prose without changing stored facts, IDs, URLs or code. */
export function formatInvestigationText(text: string | null | undefined): string {
if (!text) return text ?? "";
return text.split(/(https?:\/\/\S+|tjwater:\/\/\S+|`[^`]*`)/g).map((part, index) => {
if (index % 2) return part;
return part.replace(utcRange, (original, day: string | undefined, start: string, end: string) => {
const startAt = parseTime(day ?? "2000-01-01", start);
let endAt = parseTime(day ?? "2000-01-01", end);
if (startAt === null || endAt === null) return original;
if (endAt < startAt) endAt += 24 * 60 * 60_000;
const dateOptions = { year: "numeric", month: "numeric", day: "numeric" } as const;
const crossesDay = formatDateTime(startAt, dateOptions) !== formatDateTime(endAt, dateOptions);
const startLabel = day ? formatDateTime(startAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" }) : formatTime(startAt);
const endLabel = crossesDay && day
? formatDateTime(endAt, { ...dateOptions, hour: "2-digit", minute: "2-digit" })
: `${crossesDay ? "次日 " : ""}${formatTime(endAt)}`;
return `${startLabel}–${endLabel}(UTC+8)`;
}).replace(zonedTime, (original, day: string, clock: string, zone: string) => {
const value = parseTime(day, clock, zone);
return value === null ? original : `${formatDateTime(value)}(UTC+8)`;
});
}).join("");
}
export { formatDisplayText as formatInvestigationText } from "@/shared/utils/date-time-text";
@@ -2,6 +2,19 @@ import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
describe("presentation history", () => {
it("updates a retried event in place and ignores completion from its superseded attempt", () => {
const history = new PresentationViewHistory();
const id = history.begin("old attempt", "live", "调查", { key: "event-1", taskId: "task-1" });
const oldAttempt = history.entries[0];
const retryId = history.begin("retry", "live", "调查", { key: "event-1", taskId: "task-1" });
const newAttempt = history.entries[0];
const recovered = { ...history.current, records: [{ value: "recovered" }] };
history.finish(retryId, "completed", undefined, recovered, newAttempt);
history.finish(id, "cancelled", "late cancellation", emptyPresentationView(), oldAttempt);
expect(retryId).toBe(id);
expect(history.entries).toHaveLength(1);
expect(history.entries[0]).toMatchObject({ status: "completed", label: "retry", snapshot: recovered });
});
it("retains every step across clear and keeps a past view pinned while current advances", () => {
const history = new PresentationViewHistory();
for (let index = 0; index < 15; index++) {
@@ -69,3 +82,42 @@ describe("presentation history", () => {
expect(history.current.records).toEqual([]);
});
});
it("plays a frozen set of completed snapshots without following newly arriving results", () => {
const history = new PresentationViewHistory();
const first = history.record("first", "live", null);
history.update({ ...history.current, records: [{ value: 2 }] });
const second = history.record("second", "live", null);
const failed = history.begin("failed", "live", null);
history.finish(failed, "cancelled");
expect(history.startPlayback(history.entries)).toBe(true);
expect(history.selectedId).toBe(first);
expect(history.playing).toBe(true);
history.update({ ...history.current, records: [{ value: 3 }] });
history.record("new live result", "live", null);
expect(history.visible.records).toEqual([]);
expect(history.advancePlayback()).toBe(true);
expect(history.selectedId).toBe(second);
expect(history.visible.records).toEqual([{ value: 2 }]);
expect(history.advancePlayback()).toBe(false);
expect(history.playing).toBe(false);
expect(history.selectedId).toBe(second);
history.select(null);
expect(history.visible.records).toEqual([{ value: 3 }]);
});
it("pauses playback on manual seeking and scope reset", () => {
const history = new PresentationViewHistory();
const first = history.record("one", "live", null);
history.record("two", "live", null);
history.startPlayback(history.entries);
history.select(first);
expect(history.playing).toBe(false);
history.startPlayback(history.entries);
history.pausePlayback();
expect(history.advancePlayback()).toBe(false);
history.reset();
expect(history.playing).toBe(false);
expect(history.playbackIds).toEqual([]);
});

Some files were not shown because too many files have changed in this diff Show More