Compare commits

..
Author SHA1 Message Date
jiang aa9b12662e fix(tasks): accept execution scope in task responses
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
Server responses now require execution_scope, but the strict frontend schema rejected it and prevented investigation and appointment loading. Declare it in the shared response schema and align task fixtures; regression coverage exercises summaries, details and both task categories.
2026-11-08 03:44:25 +08:00
jiang b948551c1f fix(map): stabilize notifications during map navigation
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00
jiang e0fe4a5a10 fix(charts): preserve comparison types and draggable window headings
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
2026-11-08 03:04:14 +08:00
jiang 4e3ed5098e fix(workbench): retry approved task actions after dispatch failure
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
2026-11-07 22:20:04 +08:00
27 changed files with 517 additions and 39 deletions
+3 -1
View File
@@ -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 定制方式
@@ -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;
+2 -2
View File
@@ -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",
@@ -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");
});
+20 -8
View File
@@ -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";
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent", const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) }; ...(generation === undefined ? {} : { execution_generation: generation }) };
return { return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint", project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2, status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => { describe("operational task contract", () => {
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
"loads %s summaries and details with the Server execution scope", (taskType) => {
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
}
);
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
const detail = { ...task(), execution_scope: scope };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(() => parseOperationalTaskSummary(summary)).toThrow();
expect(() => parseOperationalTask(detail)).toThrow();
});
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => { describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 }; const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
it("keeps unknown fields and invalid generations rejected", () => { it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow(); expect(() => parseOperationalTask(task(0))).toThrow();
const value = task(); const value = task();
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
@@ -50,6 +50,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable() next_cursor: z.string().min(1).nullable()
}).strict(); }).strict();
const taskSummarySchema = z.object({ const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(), handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(), project_id: z.string().uuid(),
task_id: z.string().uuid(), task_id: z.string().uuid(),
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
}); });
const taskBase = { const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111", project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222", task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation", task_type: "event_investigation",
@@ -130,6 +131,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>
@@ -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,9 +434,11 @@ export function InvestigationTaskReport({
{actionTitle(action)} ·{" "} {actionTitle(action)} ·{" "}
{action.approval_status === "pending" {action.approval_status === "pending"
? "待审核" ? "待审核"
: action.execution_status === "not_submitted" : isRetryableInvestigationAction(action)
? "待执行" ? "待重试"
: "待核对回执"} : action.execution_status === "not_submitted"
? "待执行"
: "待核对回执"}
</span> </span>
} }
> >
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask { function task(taskType: OperationalTask["task_type"]): OperationalTask {
return { return {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111", project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222", task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType, task_type: taskType,
@@ -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);
}
+20 -7
View File
@@ -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);
+2 -2
View File
@@ -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;
@@ -61,6 +61,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
] ]
}; };
let task: OperationalTask = { let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint", project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent", lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001", revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
@@ -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, "另一项调查");
+193
View File
@@ -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"
});
});
@@ -66,6 +66,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
execution_detail: null execution_detail: null
}; };
let task: OperationalTask = { let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, project_id: projectId,
task_id: taskId, task_id: taskId,
task_type: "event_investigation", task_type: "event_investigation",
@@ -3,13 +3,13 @@ import type { OperationalTask, OperationalTaskSummary } from "../../src/features
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => { test("调查和预约任务接收 Server 返回的执行范围与空回放上下文并展示详情", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = "72000000-0000-4000-8000-000000000001"; const id = "72000000-0000-4000-8000-000000000001";
const appointmentId = "72000000-0000-4000-8000-000000000002"; const appointmentId = "72000000-0000-4000-8000-000000000002";
const summary: OperationalTaskSummary = { const summary: OperationalTaskSummary = {
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id, execution_scope: "production", project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
task_type: "event_investigation", workflow: "burst_report", task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id, priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id,
@@ -65,7 +65,7 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await panel.getByRole("button", { name: /调查任务/ }).click(); await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(summary.title, { exact: true }).first().click(); await panel.getByText(summary.title, { exact: true }).first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary); await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary);
await testInfo.attach("investigation-loaded", { body: await panel.screenshot(), contentType: "image/png" }); await testInfo.attach("investigation-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-loaded.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click(); await panel.getByRole("button", { name: /预约任务/ }).click();
await panel.getByText(appointment.title, { exact: true }).first().click(); await panel.getByText(appointment.title, { exact: true }).first().click();
@@ -73,5 +73,5 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await expect(detail).toContainText("核对预约任务详情"); await expect(detail).toContainText("核对预约任务详情");
await expect(detail).toContainText("凤阳维修组"); await expect(detail).toContainText("凤阳维修组");
await expect(detail).toContainText("凤阳核验点"); await expect(detail).toContainText("凤阳核验点");
await testInfo.attach("appointment-loaded", { body: await panel.screenshot(), contentType: "image/png" }); await testInfo.attach("appointment-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("appointment-loaded.png") }), contentType: "image/png" });
}); });
@@ -1267,6 +1267,7 @@ test.describe("外围系统", () => {
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) { if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>; const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = { operationalTask = {
execution_scope: "production",
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001", project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint", task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, lifecycle_status: "waiting_human", active_run: null,
@@ -17,6 +17,7 @@ async function expectPushedOrder(page: Page, failFirstDetail = false) {
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
}; };
const task = { const task = {
execution_scope: "production",
project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order", project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order",
workflow: "external-work-order", lifecycle_status: "open", active_run: null, workflow: "external-work-order", lifecycle_status: "open", active_run: null,
status: "queued", priority: "normal", revision: 2, execution_generation: 1, status: "queued", priority: "normal", revision: 2, execution_generation: 1,
+3 -3
View File
@@ -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.
@@ -11,6 +11,7 @@ export function investigationFixture(index: number, title: string): OperationalT
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`; const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`; const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
return { return {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report", project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1, lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查", execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",