Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b948551c1f | ||
|
|
e0fe4a5a10 | ||
|
|
4e3ed5098e | ||
|
|
8a63926284 |
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
|
|||||||
|
|
||||||
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
|
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
|
||||||
|
|
||||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||||
|
|
||||||
|
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
|
||||||
|
|
||||||
## 2. Tailwind 定制方式
|
## 2. Tailwind 定制方式
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe("answer presentation time zone", () => {
|
|||||||
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00: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.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.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_axis_name).toBe("时间");
|
||||||
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
|
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
|
||||||
expect(JSON.stringify(spec)).toBe(original);
|
expect(JSON.stringify(spec)).toBe(original);
|
||||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
@@ -36,4 +36,23 @@ describe("answer presentation time zone", () => {
|
|||||||
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
|
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
|
||||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
});
|
});
|
||||||
|
it("removes chart timezone wording while preserving the window, data and sample instants", () => {
|
||||||
|
const samples = ["2026-07-15T23:30:00Z", "2026-07-15T23:45:00Z"];
|
||||||
|
const spec: Spec = { root: "chart", elements: {
|
||||||
|
chart: { type: "Chart", props: {
|
||||||
|
title: "回放流量:487434(m³/h,上海时间 07-16 04:45–07:45)",
|
||||||
|
x_axis_name: "上海时间", x_data: ["07:30", "07:45"],
|
||||||
|
series: [{ name: "当前回放(2026-07-16,UTC+8)", data: [420, 380], sample_times: samples }]
|
||||||
|
}, children: [] }
|
||||||
|
} };
|
||||||
|
const result = withAnswerDisplayTimes(spec);
|
||||||
|
expect(result.elements.chart.props.title).toBe("回放流量:487434(m³/h,07-16 04:45–07:45)");
|
||||||
|
expect(result.elements.chart.props.x_axis_name).toBe("时间");
|
||||||
|
expect(result.elements.chart.props.x_data).toEqual(["07:30", "07:45"]);
|
||||||
|
expect(result.elements.chart.props.series).toEqual([
|
||||||
|
{ name: "当前回放(2026-07-16)", data: [420, 380], sample_times: samples }
|
||||||
|
]);
|
||||||
|
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Spec } from "@json-render/core";
|
import type { Spec } from "@json-render/core";
|
||||||
import { formatDisplayText } from "@/shared/utils/date-time-text";
|
import { formatDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||||
|
|
||||||
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
|
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
|
||||||
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
|
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
|
||||||
@@ -23,14 +23,7 @@ export function withAnswerDisplayTimes(spec: Spec): Spec {
|
|||||||
props.columns = columns.map(timeAxis);
|
props.columns = columns.map(timeAxis);
|
||||||
}
|
}
|
||||||
if (node.type === "Chart") {
|
if (node.type === "Chart") {
|
||||||
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
|
Object.assign(props, withChartDisplayTimes(props as Parameters<typeof withChartDisplayTimes>[0]));
|
||||||
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 }];
|
return [id, { ...node, props }];
|
||||||
})) };
|
})) };
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
|||||||
const start = index(proposed[0], labels.length);
|
const start = index(proposed[0], labels.length);
|
||||||
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
|
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
|
||||||
const hidden = indices(state.hidden, series.length);
|
const hidden = indices(state.hidden, series.length);
|
||||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
|
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
|
||||||
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
|
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
|
||||||
const data = answerChartTableData(node, chart);
|
const data = answerChartTableData(node, chart);
|
||||||
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
|
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
|||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
title: shortText,
|
title: shortText,
|
||||||
chart_type: z.enum(["line", "bar"]),
|
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||||
x_axis_name: z.string().max(128).optional(),
|
x_axis_name: z.string().max(128).optional(),
|
||||||
y_axis_name: z.string().max(128).optional(),
|
y_axis_name: z.string().max(128).optional(),
|
||||||
x_data: z.array(shortText).min(1).max(100),
|
x_data: z.array(shortText).min(1).max(100),
|
||||||
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
description:
|
description:
|
||||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
|
||||||
},
|
},
|
||||||
Evidence: {
|
Evidence: {
|
||||||
props: z
|
props: z
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
|||||||
|
|
||||||
export type AnswerChartProps = {
|
export type AnswerChartProps = {
|
||||||
title: string;
|
title: string;
|
||||||
chart_type: "line" | "bar";
|
chart_type: "line" | "bar" | "time_comparison";
|
||||||
x_data: string[];
|
x_data: string[];
|
||||||
series: SafeChartSeries[];
|
series: SafeChartSeries[];
|
||||||
x_axis_name?: string;
|
x_axis_name?: string;
|
||||||
@@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
|||||||
const numberLabel = chartValueLabel;
|
const numberLabel = chartValueLabel;
|
||||||
const option = (type: "line" | "bar") => {
|
const option = (type: "line" | "bar") => {
|
||||||
const horizontal = type === "bar" && horizontalBars;
|
const horizontal = type === "bar" && horizontalBars;
|
||||||
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
|
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||||
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||||
import { memo, type ReactNode } from "react";
|
import { memo, type ReactNode } from "react";
|
||||||
|
import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||||
import { StatusIcon } from "@/shared/ui/status";
|
import { StatusIcon } from "@/shared/ui/status";
|
||||||
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
||||||
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||||
@@ -78,8 +79,12 @@ export const AgentChartView = memo(function AgentChartView({ chart, embedded = f
|
|||||||
});
|
});
|
||||||
|
|
||||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
||||||
const spec = parseChartSpec(envelope.spec);
|
const parsed = parseChartSpec(envelope.spec);
|
||||||
const data = normalizeSafeChartData(envelope.data, 100);
|
const raw = normalizeSafeChartData(envelope.data, 100);
|
||||||
|
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
|
||||||
|
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
|
||||||
|
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
|
||||||
|
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
|
||||||
|
|
||||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||||
@@ -206,7 +211,7 @@ function getEnvelopeIcon(envelope: UIEnvelope) {
|
|||||||
function getEnvelopeTitle(envelope: UIEnvelope) {
|
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||||
if (envelope.kind === "chart") {
|
if (envelope.kind === "chart") {
|
||||||
const spec = parseChartSpec(envelope.spec);
|
const spec = parseChartSpec(envelope.spec);
|
||||||
return spec.title ?? "Agent 图表";
|
return formatChartDisplayText(spec.title ?? "Agent 图表");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.kind === "registered_component") {
|
if (envelope.kind === "registered_component") {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
|||||||
const chartSchema = z
|
const chartSchema = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().max(200),
|
title: z.string().max(200),
|
||||||
chart_type: z.enum(["line", "bar"]),
|
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||||
x_axis_name: z.string().max(128).optional(),
|
x_axis_name: z.string().max(128).optional(),
|
||||||
y_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),
|
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||||
|
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
|
||||||
|
|
||||||
|
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
|
||||||
|
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it("keeps initial healthy sources quiet and announces recovery once", () => {
|
||||||
|
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
|
||||||
|
expect(showMapNotice).not.toHaveBeenCalled();
|
||||||
|
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
|
||||||
|
expect(showMapNotice).not.toHaveBeenCalled();
|
||||||
|
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
|
||||||
|
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
|
||||||
|
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
|
||||||
|
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
|
||||||
|
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
|
||||||
|
expect(showMapNotice).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
expect(dismissMapNotice).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a degraded source in place and only removes it when unmounted", () => {
|
||||||
|
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
|
||||||
|
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
|
||||||
|
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
|
||||||
|
expect(showMapNotice).toHaveBeenCalledTimes(3);
|
||||||
|
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
expect(dismissMapNotice).toHaveBeenCalledWith("source");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
|
||||||
|
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
|
||||||
|
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
|
||||||
|
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||||
|
rerender(<MapErrorNotice id="new" message="另一项错误" />);
|
||||||
|
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
|
||||||
|
unmount();
|
||||||
|
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loading visible while its title or message changes", () => {
|
||||||
|
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
|
||||||
|
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
|
||||||
|
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
|
||||||
|
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type CSSProperties } from "react";
|
import { useEffect, useRef, type CSSProperties } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||||
import type { MapNoticeTone } from "./notice-types";
|
import type { MapNoticeTone } from "./notice-types";
|
||||||
@@ -17,10 +17,11 @@ export function MapToaster() {
|
|||||||
position="top-center"
|
position="top-center"
|
||||||
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
|
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
|
||||||
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
|
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
|
||||||
|
swipeDirections={[]}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
unstyled: true,
|
unstyled: true,
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: "group pointer-events-none",
|
toast: "group pointer-events-auto",
|
||||||
closeButton:
|
closeButton:
|
||||||
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
|
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ export function MapToaster() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||||
|
useNoticeCleanup(id);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showMapNotice({
|
showMapNotice({
|
||||||
id,
|
id,
|
||||||
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
|
|||||||
message,
|
message,
|
||||||
duration: Infinity
|
duration: Infinity
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => dismissMapNotice(id);
|
|
||||||
}, [id, message]);
|
}, [id, message]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
|
|||||||
title?: string;
|
title?: string;
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
}) {
|
}) {
|
||||||
|
useNoticeCleanup(id);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showMapNotice({
|
showMapNotice({
|
||||||
id,
|
id,
|
||||||
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
|
|||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
dismissible: false
|
dismissible: false
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => dismissMapNotice(id);
|
|
||||||
}, [id, message, title]);
|
}, [id, message, title]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
onAction
|
onAction
|
||||||
}: MapSourceStatusNoticeProps) {
|
}: MapSourceStatusNoticeProps) {
|
||||||
|
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
|
||||||
|
useNoticeCleanup(id);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const last = previous.current;
|
||||||
|
previous.current = { id, status };
|
||||||
|
// Initial healthy sources and repeated tile-load messages stay quiet.
|
||||||
|
// Success is announced when the same source recovers from a problem.
|
||||||
|
if (status === "online" && !(actionLabel && onAction)
|
||||||
|
&& (!last || last.id !== id || last.status === "online")) return;
|
||||||
|
|
||||||
showMapNotice({
|
showMapNotice({
|
||||||
id,
|
id,
|
||||||
tone: getSourceStatusTone(status),
|
tone: getSourceStatusTone(status),
|
||||||
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
onAction
|
onAction
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => dismissMapNotice(id);
|
|
||||||
}, [actionLabel, id, message, onAction, sourceName, status]);
|
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useNoticeCleanup(id: string | number) {
|
||||||
|
// Content updates reuse the toast id. Dismissing during an update races
|
||||||
|
// Sonner's asynchronous removal against the newly published content.
|
||||||
|
useEffect(() => () => dismissMapNotice(id), [id]);
|
||||||
|
}
|
||||||
|
|
||||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||||
if (status === "online") {
|
if (status === "online") {
|
||||||
return "success";
|
return "success";
|
||||||
|
|||||||
@@ -130,6 +130,48 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
|||||||
return { ...props, rerender };
|
return { ...props, rerender };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it.each(["failed", "unsupported"] as const)("retries an approved %s action using its current version", async (status) => {
|
||||||
|
let finish!: () => void;
|
||||||
|
const onExecute = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
|
||||||
|
const task: OperationalTask = { ...taskBase,
|
||||||
|
permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||||
|
actions: [{ ...taskBase.actions[0], approval_status: "approved", execution_status: status }]
|
||||||
|
};
|
||||||
|
const props = taskPanel(task, { onExecute });
|
||||||
|
const section = screen.getByRole("region", { name: "当前需处理" });
|
||||||
|
expect(section).toHaveTextContent("待重试");
|
||||||
|
const retry = within(section).getByRole("button", { name: "重试执行" });
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(retry).toBeDisabled();
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(onExecute).toHaveBeenCalledExactlyOnceWith(task.actions[0].action_id, task.actions[0].version);
|
||||||
|
expect(props.onDecision).not.toHaveBeenCalled();
|
||||||
|
finish();
|
||||||
|
await waitFor(() => expect(retry).toBeEnabled());
|
||||||
|
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
|
||||||
|
actions: [{ ...task.actions[0], version: 4, execution_status: "submitted" }]
|
||||||
|
}} />);
|
||||||
|
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "核对执行回执" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps retry disabled without work-order execution permission", () => {
|
||||||
|
const props = taskPanel({ ...taskBase, actions: [{ ...taskBase.actions[0],
|
||||||
|
approval_status: "approved", execution_status: "unsupported"
|
||||||
|
}] });
|
||||||
|
const retry = screen.getByRole("button", { name: "重试执行" });
|
||||||
|
expect(retry).toBeDisabled();
|
||||||
|
fireEvent.click(retry);
|
||||||
|
expect(props.onExecute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["pending", "rejected"] as const)("does not retry a %s action", (approval) => {
|
||||||
|
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||||
|
actions: [{ ...taskBase.actions[0], approval_status: approval, execution_status: "failed" }]
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
||||||
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
||||||
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
OperationalTaskDataSnapshot
|
OperationalTaskDataSnapshot
|
||||||
} from "../api/operational-task-client";
|
} from "../api/operational-task-client";
|
||||||
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
|
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
|
||||||
|
import { isRetryableInvestigationAction } from "../utils/investigation-presentation";
|
||||||
|
|
||||||
type ExecutionNode = {
|
type ExecutionNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -568,6 +569,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
|||||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||||
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
|
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
|
||||||
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
|
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
|
||||||
|
{isRetryableInvestigationAction(action) ? <Button size="sm" disabled={!canExecute || submitting !== null || stale} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}重试执行</Button> : null}
|
||||||
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
|
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
|||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||||
|
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||||
@@ -295,13 +296,13 @@ export function InvestigationPresentationPanel({
|
|||||||
<Card
|
<Card
|
||||||
data-investigation-chart={item.chart_id}
|
data-investigation-chart={item.chart_id}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={item.chart.title}
|
aria-label={formatChartDisplayText(item.chart.title)}
|
||||||
className="border-border shadow-none"
|
className="border-border shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||||
{item.chart.title}
|
{formatChartDisplayText(item.chart.title)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||||
@@ -310,7 +311,7 @@ export function InvestigationPresentationPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<FloatingWindow title={item.chart.title} fillContent
|
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
|
||||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
||||||
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||||
<AgentChartView chart={item.chart} embedded expanded />
|
<AgentChartView chart={item.chart} embedded expanded />
|
||||||
@@ -323,7 +324,7 @@ export function InvestigationPresentationPanel({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
disabled={reviewing}
|
disabled={reviewing}
|
||||||
aria-label={`关闭图表 ${item.chart.title}`}
|
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
|
||||||
onClick={() => onCloseChart(item.chart_id)}
|
onClick={() => onCloseChart(item.chart_id)}
|
||||||
>
|
>
|
||||||
<X size={14} aria-hidden />
|
<X size={14} aria-hidden />
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type {
|
|||||||
import {
|
import {
|
||||||
investigationDiagram,
|
investigationDiagram,
|
||||||
investigationStatus,
|
investigationStatus,
|
||||||
isPendingInvestigationAction
|
isPendingInvestigationAction,
|
||||||
|
isRetryableInvestigationAction
|
||||||
} from "../utils/investigation-presentation";
|
} from "../utils/investigation-presentation";
|
||||||
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
||||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||||
@@ -433,6 +434,8 @@ export function InvestigationTaskReport({
|
|||||||
{actionTitle(action)} ·{" "}
|
{actionTitle(action)} ·{" "}
|
||||||
{action.approval_status === "pending"
|
{action.approval_status === "pending"
|
||||||
? "待审核"
|
? "待审核"
|
||||||
|
: isRetryableInvestigationAction(action)
|
||||||
|
? "待重试"
|
||||||
: action.execution_status === "not_submitted"
|
: action.execution_status === "not_submitted"
|
||||||
? "待执行"
|
? "待执行"
|
||||||
: "待核对回执"}
|
: "待核对回执"}
|
||||||
|
|||||||
@@ -416,9 +416,8 @@ export function useWorkbenchMap({
|
|||||||
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("sourcedataabort", (event) => {
|
// Moving or zooming cancels obsolete tile requests. Cancellation is part
|
||||||
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
|
// of navigation; source failures are reported by the error handler below.
|
||||||
});
|
|
||||||
|
|
||||||
map.on("error", (event) => {
|
map.on("error", (event) => {
|
||||||
const message = event.error?.message ?? "地图渲染异常";
|
const message = event.error?.message ?? "地图渲染异常";
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function investigationStatus(task: OperationalTask): string {
|
|||||||
if (task.lifecycle_status === "cancelled") return "本轮已取消";
|
if (task.lifecycle_status === "cancelled") return "本轮已取消";
|
||||||
if (task.questions?.some((question) => question.status === "pending" && question.execution_generation === task.execution_generation)) return "等待回答";
|
if (task.questions?.some((question) => question.status === "pending" && question.execution_generation === task.execution_generation)) return "等待回答";
|
||||||
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
|
if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核";
|
||||||
|
if (task.actions.some(isRetryableInvestigationAction)) return "待重试动作";
|
||||||
if (
|
if (
|
||||||
task.actions.some(
|
task.actions.some(
|
||||||
(action) =>
|
(action) =>
|
||||||
@@ -105,6 +106,11 @@ export function isPendingInvestigationAction(action: OperationalTask["actions"][
|
|||||||
return (
|
return (
|
||||||
action.approval_status === "pending" ||
|
action.approval_status === "pending" ||
|
||||||
(action.approval_status === "approved" &&
|
(action.approval_status === "approved" &&
|
||||||
["not_submitted", "submitted", "unknown"].includes(action.execution_status))
|
["not_submitted", "submitted", "unknown"].includes(action.execution_status)) ||
|
||||||
|
isRetryableInvestigationAction(action)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRetryableInvestigationAction(action: OperationalTask["actions"][number]) {
|
||||||
|
return action.approval_status === "approved" && ["failed", "unsupported"].includes(action.execution_status);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||||
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
|
import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||||
import { investigationActionLabels } from "./investigation-dev-preview";
|
import { investigationActionLabels } from "./investigation-dev-preview";
|
||||||
import type { PresentationView } from "./presentation-view-history";
|
import type { PresentationView } from "./presentation-view-history";
|
||||||
@@ -8,11 +9,11 @@ export function presentationCommandLabel(
|
|||||||
) {
|
) {
|
||||||
const detail =
|
const detail =
|
||||||
command.action === "open_chart"
|
command.action === "open_chart"
|
||||||
? command.chart.title
|
? formatChartDisplayText(command.chart.title)
|
||||||
: command.action === "set_overlay"
|
: command.action === "set_overlay"
|
||||||
? command.title
|
? command.title
|
||||||
: command.action === "close_chart"
|
: command.action === "close_chart"
|
||||||
? view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title
|
? formatChartDisplayText(view.charts.find((chart) => chart.chart_id === command.chart_id)?.chart.title ?? "")
|
||||||
: "feature_ids" in command
|
: "feature_ids" in command
|
||||||
? command.feature_ids.slice(0, 3).join("、")
|
? command.feature_ids.slice(0, 3).join("、")
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Rnd } from "react-rnd";
|
import { Rnd } from "react-rnd";
|
||||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||||
@@ -48,16 +48,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
|
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||||
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
|
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||||
|
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||||
|
const step = headingHeight + 8;
|
||||||
|
const minHeight = Math.min(260, window.innerHeight - 16);
|
||||||
|
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
|
||||||
|
const slot = others.length % slots;
|
||||||
|
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
|
||||||
|
// Keep the preceding headings exposed. Shorter viewports reduce content
|
||||||
|
// height instead of clamping several windows onto the same title bar.
|
||||||
|
// Absolute placement also makes StrictMode's repeated setup idempotent.
|
||||||
|
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
|
||||||
|
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
|
||||||
raiseFloatingWindow(id);
|
raiseFloatingWindow(id);
|
||||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||||
focusOnOpen.current = false;
|
focusOnOpen.current = false;
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||||
setBounds((current) => fitWindow(current));
|
setBounds((current) => {
|
||||||
|
const minimumHeight = Math.min(260, window.innerHeight - 16);
|
||||||
|
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
|
||||||
|
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", resize);
|
window.addEventListener("resize", resize);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -75,9 +90,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
|||||||
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
|
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
const offset = (order.length % 5) * 24;
|
|
||||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
|
|
||||||
setMaximized(false);
|
setMaximized(false);
|
||||||
focusOnOpen.current = true;
|
focusOnOpen.current = true;
|
||||||
setManuallyOpen(true);
|
setManuallyOpen(true);
|
||||||
|
|||||||
@@ -41,3 +41,32 @@ export function formatDisplayText(text: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chart labels use the display clock without repeating its zone to readers. */
|
||||||
|
export function formatChartDisplayText(value: string): string {
|
||||||
|
return formatDisplayText(value)
|
||||||
|
.replace(/[((]\s*(?:上海时间|Asia\/Shanghai|UTC\s*\+\s*8(?::00)?)\s*[))]/g, "")
|
||||||
|
.replace(/(?:上海时间|Asia\/Shanghai|UTC\s*\+\s*8(?::00)?)(?![\w:+-])\s*/g, "")
|
||||||
|
.replace(/[,,]\s*([))])/g, "$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartDisplayFields = {
|
||||||
|
title: string;
|
||||||
|
x_axis_name?: string;
|
||||||
|
y_axis_name?: string;
|
||||||
|
x_data: string[];
|
||||||
|
series: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preserve values and original sample timestamps; convert only visible labels. */
|
||||||
|
export function withChartDisplayTimes<T extends ChartDisplayFields>(chart: T): T {
|
||||||
|
const utcAxis = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/.test(chart.x_axis_name ?? "");
|
||||||
|
const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
|
||||||
|
return { ...chart,
|
||||||
|
title: formatChartDisplayText(chart.title),
|
||||||
|
...(chart.x_axis_name ? { x_axis_name: formatChartDisplayText(chart.x_axis_name.replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8")) || "时间" } : {}),
|
||||||
|
x_data: chart.x_data.map((label) => formatChartDisplayText(utcAxis && bareTime.test(label) ? `${label} UTC` : label)),
|
||||||
|
series: chart.series.map((series) => ({ ...series, name: formatChartDisplayText(series.name) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
|
|||||||
height: min(720px, 80dvh);
|
height: min(720px, 80dvh);
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
/* Layout only: keep the shared acrylic material and its shadows unclipped. */
|
/* Flow layout owns stack geometry; keep Sonner opacity transitions without its absolute offsets. */
|
||||||
[data-sonner-toaster].map-notice-stack {
|
[data-sonner-toaster].map-notice-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
|
|||||||
bottom: auto;
|
bottom: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
--y: translateY(0);
|
transform: none;
|
||||||
}
|
}
|
||||||
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
|
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
|
test("已批准的派单失败动作可重试,并在提交期间禁止重复点击", async ({ page }, testInfo) => {
|
||||||
|
await mockAgentApi(page);
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.setContent('<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="reader" style="max-width:680px;margin:auto;padding:16px"></main></body></html>');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const entry = await (await fetch("/src/main.tsx")).text();
|
||||||
|
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||||
|
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||||
|
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
||||||
|
const reactModule = await import(reactUrl);
|
||||||
|
const React = reactModule.default ?? reactModule;
|
||||||
|
const clientModule = await import(clientUrl);
|
||||||
|
const { createRoot } = clientModule.default ?? clientModule;
|
||||||
|
const { AgentExecutionPlanPanel } = await import("/src/features/workbench/components/agent-execution-plan-panel.tsx");
|
||||||
|
const now = "2026-11-07T13:36:15Z";
|
||||||
|
let task = {
|
||||||
|
task_id: "task-retry", task_type: "event_investigation", title: "SCADA 异常调查",
|
||||||
|
summary: "候选管段515870待现场核验", execution_generation: 1,
|
||||||
|
lifecycle_status: "waiting_human", status: "waiting_human", updated_at: now,
|
||||||
|
latest_report: { revision: 2, execution_generation: 1, published_at: now, report: {
|
||||||
|
title: "现场核验工单派发状态", stage: "final", outcome: "concluded",
|
||||||
|
current_conclusion: "工单已批准,执行适配器尚未配置,现场核验未派发。",
|
||||||
|
conclusion_boundary: "事件未闭环。", recommendations: [], pending_items: [],
|
||||||
|
data_gaps: [], evidence_refs: [], diagram: null
|
||||||
|
} },
|
||||||
|
latest_progress: null, latest_plan: null, active_run: null, evidence: [], access_requests: [],
|
||||||
|
permissions: { can_execute_work_orders: true },
|
||||||
|
actions: [{ action_id: "action-retry", task_id: "task-retry", version: 4,
|
||||||
|
kind: "create_work_order", reason: "候选管段515870需现场核验", proposed_at: now,
|
||||||
|
decided_at: now, executed_at: now, approval_status: "approved", execution_status: "unsupported",
|
||||||
|
execution_detail: "尚未配置该动作的外部执行适配器", evidence_refs: [], receipt: null,
|
||||||
|
payload: { title: "候选管段515870现场漏水核查", description: "核查渗漏、阀门状态并回填照片和压力复测。",
|
||||||
|
work_order_type: "general", priority: "normal" } }]
|
||||||
|
};
|
||||||
|
const calls: { actionId: string; version: number }[] = [];
|
||||||
|
let finish: () => void;
|
||||||
|
const root = createRoot(document.getElementById("reader"));
|
||||||
|
const render = () => root.render(React.createElement(AgentExecutionPlanPanel, {
|
||||||
|
task, onContinue: () => undefined, onDecision: async () => undefined,
|
||||||
|
onReconcile: async () => undefined, onRefresh: () => undefined,
|
||||||
|
onLoadEvidenceSnapshot: async () => undefined,
|
||||||
|
onExecute: async (actionId: string, version: number) => {
|
||||||
|
calls.push({ actionId, version });
|
||||||
|
await new Promise<void>((resolve) => { finish = resolve; });
|
||||||
|
task = { ...task, actions: [{ ...task.actions[0], version: 5,
|
||||||
|
execution_status: "submitted", execution_detail: "工单已提交,等待现场核验回执" }] };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
Object.assign(window, { retryCalls: calls, finishRetry: () => finish() });
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
const section = page.getByRole("region", { name: "当前需处理", exact: true });
|
||||||
|
await expect(section).toContainText("待重试");
|
||||||
|
await section.locator("summary").filter({ hasText: "待重试" }).click();
|
||||||
|
const retry = section.getByRole("button", { name: "重试执行", exact: true });
|
||||||
|
await expect(retry).toBeEnabled();
|
||||||
|
await retry.scrollIntoViewIfNeeded();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("retry-desktop.png") });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await retry.scrollIntoViewIfNeeded();
|
||||||
|
await expect(retry).toBeInViewport();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("retry-mobile.png") });
|
||||||
|
await retry.click();
|
||||||
|
await expect(retry).toBeDisabled();
|
||||||
|
expect(await page.evaluate(() => (window as unknown as { retryCalls: unknown[] }).retryCalls)).toEqual([
|
||||||
|
{ actionId: "action-retry", version: 4 }
|
||||||
|
]);
|
||||||
|
await page.evaluate(() => (window as unknown as { finishRetry: () => void }).finishRetry());
|
||||||
|
await expect(retry).toHaveCount(0);
|
||||||
|
await expect(section.getByRole("button", { name: "核对执行回执" })).toBeVisible();
|
||||||
|
await expect(section).toContainText("工单已提交,等待现场核验回执");
|
||||||
|
});
|
||||||
@@ -9,6 +9,50 @@ const chart = (index: number) => ({
|
|||||||
|
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
for (const height of [900, 720]) {
|
||||||
|
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height });
|
||||||
|
const task = investigationFixture(1, "多图窗口拖动核查");
|
||||||
|
const mock = await mockInvestigationPresentation(page, [task]);
|
||||||
|
await selectInvestigation(page, task.title);
|
||||||
|
for (let index = 1; index <= 4; index++) {
|
||||||
|
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
|
||||||
|
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
|
||||||
|
for (let index = 1; index <= 4; index++) {
|
||||||
|
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||||
|
const heading = popup.locator(".floating-window-heading");
|
||||||
|
const box = (await heading.boundingBox())!;
|
||||||
|
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
|
||||||
|
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target))
|
||||||
|
.toBe(await popup.getAttribute("aria-labelledby"));
|
||||||
|
}
|
||||||
|
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
|
||||||
|
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
|
||||||
|
});
|
||||||
|
for (const index of [1, 4, 2]) {
|
||||||
|
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||||
|
const before = (await popup.boundingBox())!;
|
||||||
|
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
|
||||||
|
const start = { x: heading.x + heading.width - 110, y: heading.y + heading.height / 2 };
|
||||||
|
await page.mouse.move(start.x, start.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120);
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-32);
|
||||||
|
expect((await popup.boundingBox())!.width).toBe(before.width);
|
||||||
|
expect((await popup.boundingBox())!.height).toBe(before.height);
|
||||||
|
}
|
||||||
|
await testInfo.attach(`multiple-charts-after-drag-${height}`, {
|
||||||
|
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-after-drag-${height}.png`) }), contentType: "image/png"
|
||||||
|
});
|
||||||
|
expect(mock.mutations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
|
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
|
||||||
const task = investigationFixture(1, "图表自动展示核查");
|
const task = investigationFixture(1, "图表自动展示核查");
|
||||||
const other = investigationFixture(2, "另一项调查");
|
const other = investigationFixture(2, "另一项调查");
|
||||||
@@ -156,7 +200,7 @@ test("SCADA chart axes and legends fit every nested container", async ({ page },
|
|||||||
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
|
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
|
||||||
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
|
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
|
||||||
} });
|
} });
|
||||||
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16,UTC+8)", exact: true });
|
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16)", exact: true });
|
||||||
await expect(popup).toBeVisible();
|
await expect(popup).toBeVisible();
|
||||||
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
|
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
@@ -173,3 +217,31 @@ test("SCADA chart axes and legends fit every nested container", async ({ page },
|
|||||||
body: await popup.screenshot({ path: testInfo.outputPath("scada-chart-layout.png") }), contentType: "image/png"
|
body: await popup.screenshot({ path: testInfo.outputPath("scada-chart-layout.png") }), contentType: "image/png"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("SCADA pressure and flow keep full comparison data without timezone wording", async ({ page }, testInfo) => {
|
||||||
|
const task = investigationFixture(1, "SCADA 三小时对照核查");
|
||||||
|
const mock = await mockInvestigationPresentation(page, [task]);
|
||||||
|
await selectInvestigation(page, task.title);
|
||||||
|
const labels = Array.from({ length: 13 }, (_, i) => `${String(4 + Math.floor((45 + i * 15) / 60)).padStart(2, "0")}:${String((45 + i * 15) % 60).padStart(2, "0")}`);
|
||||||
|
for (const [metric, device, unit] of [["压力", "123748", "m"], ["流量", "487434", "m³/h"]]) {
|
||||||
|
await mock.emit(task, { action: "open_chart", chart_id: `scada-${device}`, evidence_refs: ["synthetic:pressure"], chart: {
|
||||||
|
title: `回放${metric}:${device}(${unit},上海时间 07-16 04:45–07:45)`, chart_type: "line",
|
||||||
|
x_axis_name: "上海时间", y_axis_name: `${metric}(${unit})`, x_data: labels,
|
||||||
|
series: [{ name: "当前回放(2026-07-16)", type: "line", data: labels.map((_, i) => 30 + i) },
|
||||||
|
...Array.from({ length: 7 }, (_, day) => ({ name: `参考 07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
|
||||||
|
data: labels.map((_, i) => i < 10 ? null : 28 + day) }))]
|
||||||
|
} });
|
||||||
|
const popup = page.getByRole("dialog", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true });
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup.getByText(/上海时间|UTC\+8|Asia\/Shanghai/)).toHaveCount(0);
|
||||||
|
await expect(popup.getByRole("img", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true })).toBeVisible();
|
||||||
|
await testInfo.attach(`scada-${device}-comparison`, { body: await popup.screenshot(), contentType: "image/png" });
|
||||||
|
await popup.getByRole("tab", { name: "数据", exact: true }).click();
|
||||||
|
const table = popup.getByRole("table");
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
await expect(table.getByRole("row")).toHaveCount(14);
|
||||||
|
await expect(table.getByRole("columnheader")).toHaveCount(9);
|
||||||
|
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await mockInvestigationPresentation(page, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openMap(page: Page) {
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearInitialNotices(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||||
|
(await import(path)).dismissMapNotice();
|
||||||
|
});
|
||||||
|
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("tile cancellation events during map navigation do not announce source outages", async ({ page }) => {
|
||||||
|
await openMap(page);
|
||||||
|
await clearInitialNotices(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const map = window.__waterNetworkMap!;
|
||||||
|
Object.assign(window, { noticeTileAborts: 0, noticeFalseOutages: 0 });
|
||||||
|
map.on("sourcedataabort", () => {
|
||||||
|
const state = window as unknown as { noticeTileAborts: number };
|
||||||
|
state.noticeTileAborts++;
|
||||||
|
});
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
for (const record of records) for (const node of record.addedNodes) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue;
|
||||||
|
for (const notice of [node, ...node.querySelectorAll("[data-sonner-toast]")]) {
|
||||||
|
if (notice.matches("[data-sonner-toast]") && notice.textContent?.includes("GeoServer MVT 降级运行")) {
|
||||||
|
(window as unknown as { noticeFalseOutages: number }).noticeFalseOutages++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { subtree: true, childList: true });
|
||||||
|
map.jumpTo({ zoom: 14 });
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.mouse.move(220, 420);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
// Deliver the same cancellation event MapLibre emits for obsolete tiles,
|
||||||
|
// independently of cache timing or how quickly the fixture responds.
|
||||||
|
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedataabort", { sourceId: "pipes" }));
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
await expect.poll(() => page.evaluate(() => (window as unknown as { noticeTileAborts: number }).noticeTileAborts)).toBeGreaterThan(0);
|
||||||
|
expect(await page.evaluate(() => (window as unknown as { noticeFalseOutages: number }).noticeFalseOutages)).toBe(0);
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 降级运行" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed map source recovers in the same visible notice without entering removal", async ({ page }) => {
|
||||||
|
await openMap(page);
|
||||||
|
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
await clearInitialNotices(page);
|
||||||
|
await page.evaluate(() => window.__waterNetworkMap!.fire("error", { sourceId: "pipes", error: new Error("notice-regression-test") }));
|
||||||
|
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
await expect(notice).toHaveCSS("opacity", "1");
|
||||||
|
const node = await notice.elementHandle();
|
||||||
|
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
expect(await node!.evaluate((element) => ({
|
||||||
|
connected: element.isConnected,
|
||||||
|
removed: element.getAttribute("data-removed"),
|
||||||
|
text: element.textContent
|
||||||
|
}))).toMatchObject({ connected: true, removed: "false", text: expect.stringContaining("GeoServer MVT 已连接") });
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notice surfaces capture input above the task ticker while the outside map remains draggable", async ({ page }, testInfo) => {
|
||||||
|
await openMap(page);
|
||||||
|
await clearInitialNotices(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||||
|
(await import(path)).showMapNotice({ id: "notice-input-regression", tone: "warning", title: "通知交互回归",
|
||||||
|
message: "通知覆盖任务浮动条,滚轮与拖动由通知接收。", actionLabel: "查看任务", onAction: () => {} });
|
||||||
|
});
|
||||||
|
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "通知交互回归" });
|
||||||
|
await expect(notice).toHaveCSS("opacity", "1");
|
||||||
|
const ticker = page.locator(".agent-task-ticker");
|
||||||
|
await expect(ticker).toBeVisible();
|
||||||
|
const point = await page.evaluate(() => {
|
||||||
|
const ticker = document.querySelector(".agent-task-ticker")!;
|
||||||
|
const r = ticker.getBoundingClientRect();
|
||||||
|
Object.assign(window, { noticeTickerWheelEvents: 0 });
|
||||||
|
ticker.addEventListener("wheel", () => {
|
||||||
|
(window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents++;
|
||||||
|
});
|
||||||
|
return { x: r.x + 170, y: r.y + 25 };
|
||||||
|
});
|
||||||
|
expect(await notice.evaluate((element, point) => element.contains(document.elementFromPoint(point.x, point.y)), point)).toBe(true);
|
||||||
|
const before = await notice.boundingBox();
|
||||||
|
await page.mouse.move(point.x, point.y);
|
||||||
|
await page.mouse.wheel(0, 120);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(point.x + 35, point.y + 15, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(notice).toHaveCount(1);
|
||||||
|
expect(await notice.boundingBox()).toEqual(before);
|
||||||
|
expect(await page.evaluate(() => (window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents)).toBe(0);
|
||||||
|
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
|
||||||
|
await page.mouse.move(220, 420);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(330, 450, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
|
||||||
|
await testInfo.attach("notice-above-ticker", { body: await page.screenshot({ path: testInfo.outputPath("notice-priority.png") }), contentType: "image/png" });
|
||||||
|
await notice.getByRole("button", { name: "关闭通知" }).click();
|
||||||
|
await expect(notice).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple notices stay anchored while panning and source recovery exits without a downward jump", async ({ page }, testInfo) => {
|
||||||
|
await openMap(page);
|
||||||
|
await clearInitialNotices(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||||
|
const notices = await import(path);
|
||||||
|
notices.showMapNotice({ id: "scada-position-regression", tone: "error", title: "SCADA 位置回归",
|
||||||
|
message: "监测设备暂不可用。", duration: Infinity });
|
||||||
|
notices.showMapNotice({ id: "geoserver-position-regression", tone: "error", title: "GeoServer MVT 不可用",
|
||||||
|
message: "地图数据源请求失败。", duration: Infinity });
|
||||||
|
});
|
||||||
|
const recovery = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
|
||||||
|
await expect(recovery).toBeVisible();
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||||
|
(await import(path)).showMapNotice({ id: "report-position-regression", title: "报告位置回归",
|
||||||
|
message: "报告入口持续保留。", actionLabel: "查看报告", onAction: () => {} });
|
||||||
|
});
|
||||||
|
const scada = page.locator("[data-sonner-toast]").filter({ hasText: "SCADA 位置回归" });
|
||||||
|
const report = page.locator("[data-sonner-toast]").filter({ hasText: "报告位置回归" });
|
||||||
|
await expect(report).toHaveCSS("opacity", "1");
|
||||||
|
await expect.poll(() => page.locator("[data-sonner-toaster]").evaluate((element) =>
|
||||||
|
element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length
|
||||||
|
)).toBe(0);
|
||||||
|
const boxes = await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()]);
|
||||||
|
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
|
||||||
|
const track = await recovery.evaluateHandle((element) => {
|
||||||
|
const cards = ["报告位置回归", "GeoServer MVT 不可用", "SCADA 位置回归"].map((title) =>
|
||||||
|
[...document.querySelectorAll("[data-sonner-toast]")].find((card) => card.textContent?.includes(title))!);
|
||||||
|
const state = { stopped: false, panning: true, panY: [] as number[][], liveY: [] as number[], exitY: [] as number[] };
|
||||||
|
const sample = () => {
|
||||||
|
if (state.stopped || !element.isConnected) return;
|
||||||
|
if (state.panning) state.panY.push(cards.map((card) => card.getBoundingClientRect().y));
|
||||||
|
const y = element.getBoundingClientRect().y;
|
||||||
|
(element.getAttribute("data-removed") === "true" ? state.exitY : state.liveY).push(y);
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
sample();
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await page.mouse.move(220, 420);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
expect(await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
|
||||||
|
expect(await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()])).toEqual(boxes);
|
||||||
|
await track.evaluate((state) => { state.panning = false; });
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const path = "/src/features/map/core/components/notice-actions.tsx";
|
||||||
|
(await import(path)).showMapNotice({ id: "geoserver-position-regression", tone: "success", title: "GeoServer MVT 已连接",
|
||||||
|
message: "地图数据源已恢复正常。", duration: 700 });
|
||||||
|
});
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
|
||||||
|
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toHaveCount(0, { timeout: 7000 });
|
||||||
|
const samples = await track.evaluate((state) => { state.stopped = true; return state; });
|
||||||
|
expect(samples.panY.length).toBeGreaterThan(0);
|
||||||
|
for (const positions of samples.panY) {
|
||||||
|
positions.forEach((y, index) => expect(Math.abs(y - boxes[index]!.y)).toBeLessThan(1));
|
||||||
|
}
|
||||||
|
expect(samples.liveY.length).toBeGreaterThan(0);
|
||||||
|
expect(samples.exitY.length).toBeGreaterThan(0);
|
||||||
|
for (const y of [...samples.liveY, ...samples.exitY]) expect(y).toBeLessThanOrEqual(boxes[1]!.y + 1);
|
||||||
|
expect((await report.boundingBox())!.y).toBe(boxes[0]!.y);
|
||||||
|
const reportBox = (await report.boundingBox())!;
|
||||||
|
const scadaBox = (await scada.boundingBox())!;
|
||||||
|
expect(scadaBox.y).toBeCloseTo(reportBox.y + reportBox.height + 8, 0);
|
||||||
|
await testInfo.attach("notice-positions-after-pan-and-recovery", {
|
||||||
|
body: await page.screenshot({ path: testInfo.outputPath("notice-positions.png") }), contentType: "image/png"
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,8 @@ const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor(
|
|||||||
const block = {
|
const block = {
|
||||||
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
|
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
|
||||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||||
title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)",
|
title: "压力与前七天同时刻对照(m)", chart_type: "time_comparison", y_axis_name: "压力(m)", x_axis_name: "时间",
|
||||||
x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)),
|
x_data: times,
|
||||||
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
|
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
|
||||||
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
|
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
|
||||||
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
|
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
|
||||||
@@ -45,7 +45,7 @@ for (const width of [1440, 375, 320]) {
|
|||||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||||
const ui = panel.locator(".answer-ui-chart");
|
const ui = panel.locator(".answer-ui-chart");
|
||||||
await expect(ui).toBeVisible();
|
await expect(ui).toBeVisible();
|
||||||
await expect(ui).toContainText("时间(UTC+8)");
|
await expect(ui.locator("svg text").filter({ hasText: /^时间$/ })).toBeVisible();
|
||||||
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
|
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
|
||||||
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
|
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
|
||||||
// Each historical day has exactly three unconnected SVG scatter symbols.
|
// Each historical day has exactly three unconnected SVG scatter symbols.
|
||||||
|
|||||||
Reference in New Issue
Block a user