Compare commits

..
Author SHA1 Message Date
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
jiang 8a63926284 fix(charts): simplify time labels in investigation views
Generic Container CI/CD / test-build-publish (push) Successful in 1m25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m26s
2026-11-07 21:03:43 +08:00
12 changed files with 232 additions and 25 deletions
@@ -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 }];
})) }; })) };
@@ -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") {
@@ -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,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>
} }
> >
@@ -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;
+29
View File
@@ -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) }))
};
}
@@ -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("工单已提交,等待现场核验回执");
});
@@ -156,7 +156,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 +173,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();
}
});