diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index ac2bbb6..498d5e0 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -1283,6 +1283,10 @@ function BackendMessageList({ )} >
+ {message.role === "assistant" && message.taskContext ?

+ 依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`} + {message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`} +

: null} {message.role === "assistant" && !qaOnly ? ( ; + +export const agentTaskContextReferenceSchema = z + .object({ + task_id: z.string().uuid(), + execution_generation: z.number().int().positive().nullable(), + report_revision: z.number().int().positive().nullable() + }) + .strict(); + +export type AgentTaskContextReference = z.infer; diff --git a/src/features/agent/types.ts b/src/features/agent/types.ts index 40fa91f..b7602ae 100644 --- a/src/features/agent/types.ts +++ b/src/features/agent/types.ts @@ -6,6 +6,7 @@ export type AgentChatMessage = { permissions?: AgentPermissionRequest[]; questions?: AgentQuestionRequest[]; todos?: AgentTodoUpdate; + taskContext?: import("./task-context").AgentTaskContextReference; }; export type AgentStreamRenderMessageState = { diff --git a/src/features/workbench/api/investigation-report.test.ts b/src/features/workbench/api/investigation-report.test.ts new file mode 100644 index 0000000..508bf72 --- /dev/null +++ b/src/features/workbench/api/investigation-report.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { investigationReportSchema } from "./investigation-report"; + +const base = { + title: "调查报告", + current_conclusion: "无法确定原因", + conclusion_boundary: "仅有上游监测", + evidence_refs: ["query-1"], + recommendations: [], + pending_items: [] +}; +const result = { + ...base, + stage: "final", + outcome: "insufficient_data", + stop_reason: "缺少采样", + data_gaps: [ + { + gap_id: "g1", + node_id: "read", + missing_data: "末梢采样", + scope: "投诉小区,投诉时段", + impact: "无法确定异常来源", + required_data: "该时段采样结果", + evidence_refs: ["query-1"], + blocking: true + } + ] +}; + +describe("investigation report read contract", () => { + it("preserves unknown historical facts instead of inventing a result or empty gap list", () => { + const legacy = investigationReportSchema.parse(base); + expect(legacy.outcome).toBeUndefined(); + expect(legacy.data_gaps).toBeUndefined(); + expect( + investigationReportSchema.parse({ + ...base, + stage: null, + outcome: null, + data_gaps: null, + stop_reason: null + }).data_gaps + ).toBeNull(); + }); + it("accepts an evidenced final insufficient-data result", () => { + expect(investigationReportSchema.parse(result).outcome).toBe("insufficient_data"); + }); + it("reads a narrative diagram without requiring a legacy execution plan", () => { + const value = investigationReportSchema.parse({ + ...result, + data_gaps: [{ ...result.data_gaps[0], node_id: null }], + diagram: { + title: "调查脉络", + mermaid: 'flowchart TD\nA["查询"] --> B["判断"]', + node_refs: [{ node_id: "A", label: "查询", evidence_refs: ["query-1"] }] + } + }); + expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]); + expect(value.data_gaps?.[0]?.node_id).toBeNull(); + }); + it.each([ + { outcome: "other" }, + { stage: "interim" }, + { stop_reason: null }, + { data_gaps: [] }, + { recommendations: ["自动派单"] }, + { pending_items: ["等人工"] }, + { outcome: "concluded" }, + { data_gaps: [{ ...result.data_gaps[0], evidence_refs: [] }] }, + { data_gaps: [result.data_gaps[0], result.data_gaps[0]] }, + { schema: "invented" } + ])("rejects inconsistent business facts %j", (changes) => { + expect(() => investigationReportSchema.parse({ ...result, ...changes })).toThrow(); + }); +}); diff --git a/src/features/workbench/api/investigation-report.ts b/src/features/workbench/api/investigation-report.ts new file mode 100644 index 0000000..de4f8c2 --- /dev/null +++ b/src/features/workbench/api/investigation-report.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import type { AgentTaskDiscussionContext } from "@/features/agent"; + +export type InvestigationDiscussion = { prompt?: string; context?: AgentTaskDiscussionContext }; + +const text = z.string().trim().min(1); +export const investigationDiagramSchema = z + .object({ + title: text, + mermaid: text + .max(20000) + .refine( + (source) => + /^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source) && + !source.includes("```") && + !source.includes("%%{") && + !/(?:^|[;\n])\s*click\b/.test(source), + "调查图只接受不含指令或回调的 Mermaid 流程描述" + ), + node_refs: z + .array( + z + .object({ + node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/), + label: text, + evidence_refs: z.array(text).default([]), + action_refs: z.array(z.string().uuid()).default([]) + }) + .strict() + ) + .max(100) + .default([]) + }) + .strict() + .superRefine((diagram, ctx) => { + if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" }); + } + }); +const dataGapSchema = z + .object({ + gap_id: text, + node_id: text.nullish(), + missing_data: text, + scope: text, + impact: text, + required_data: text, + evidence_refs: z.array(text).min(1), + blocking: z.boolean() + }) + .strict(); + +/** Historical omissions remain unknown. Never infer outcomes from report prose. */ +export const investigationReportSchema = z + .object({ + title: text, + current_conclusion: text, + conclusion_boundary: text, + evidence_refs: z.array(text), + recommendations: z.array(text), + pending_items: z.array(text), + stage: z.enum(["interim", "final"]).nullish(), + outcome: z.enum(["concluded", "insufficient_data"]).nullish(), + stop_reason: text.nullish(), + data_gaps: z.array(dataGapSchema).nullish(), + diagram: investigationDiagramSchema.nullish() + }) + .strict() + .superRefine((report, ctx) => { + const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message }); + if (report.outcome == null) { + if (report.stage != null || report.stop_reason != null || report.data_gaps != null) + invalid("报告结构化字段必须一起提供"); + return; + } + if (report.stage == null || report.data_gaps == null) { + invalid("报告缺少阶段或数据缺口清单"); + return; + } + if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) + invalid("数据缺口编号重复"); + const blocking = report.data_gaps.some((gap) => gap.blocking); + if (report.outcome === "insufficient_data") { + if (report.stage !== "final" || !report.stop_reason || !blocking) + invalid("证据不足必须提供最终报告、结束原因和关键缺口"); + if (report.recommendations.length || report.pending_items.length) + invalid("证据不足退出后不再生成后续处置建议或待办"); + } else if (report.stop_reason != null || blocking) + invalid("已形成结论不能包含结束原因或阻塞缺口"); + }); + +export type InvestigationReport = z.infer; +export type InvestigationDiagram = z.infer; diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts index 0e666eb..7adc59f 100644 --- a/src/features/workbench/api/operational-task-client.ts +++ b/src/features/workbench/api/operational-task-client.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { investigationReportSchema } from "./investigation-report"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client"; @@ -105,14 +106,7 @@ const planSnapshotSchema = z.object({ published_by: z.string().min(1), plan: planInputSchema }).strict(); -const reportInputSchema = z.object({ - title: z.string().min(1), - current_conclusion: z.string().min(1), - conclusion_boundary: z.string().min(1), - evidence_refs: z.array(z.string().min(1)), - recommendations: z.array(z.string().min(1)), - pending_items: z.array(z.string().min(1)) -}).strict(); +const reportInputSchema = investigationReportSchema; const reportRevisionSchema = z.object({ revision: z.number().int().positive(), execution_generation: z.number().int().positive().nullish(), @@ -121,6 +115,10 @@ const reportRevisionSchema = z.object({ published_by: z.string().min(1), report: reportInputSchema }).strict(); +const reportPageSchema = z.object({ + items: z.array(reportRevisionSchema), + next_offset: z.number().int().nonnegative().nullable(), +}).strict(); const actionReceiptSchema = z.object({ adapter: z.string().min(1), receipt_id: z.string().min(1), @@ -240,6 +238,7 @@ const notificationSchema = z.object({ lifecycle_status: z.enum(["active", "resolved"]).default("active"), subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"), subject_id: z.string().uuid().nullable().default(null), + report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(), resolved_at: workbenchDateTimeSchema.nullable().default(null), resolution_reason: z.string().min(1).nullable().default(null), status: z.enum(["unread", "read"]), @@ -285,6 +284,8 @@ export type OperationalTaskAction = z.infer; export type OperationalTaskAccessRequest = z.infer; export type OperationalTaskEvidence = z.infer; export type OperationalTaskNotification = z.infer; +export type OperationalTaskReport = z.infer; +export type OperationalTaskReportPage = z.infer; export type OperationalTaskSchedule = z.infer; export type OperationalTaskDataSnapshot = z.infer; export type ExternalWorkOrderSnapshot = z.infer; @@ -335,6 +336,12 @@ export async function fetchOperationalTask(taskId: string, getAccessToken?: Acce )); } +export async function fetchOperationalTaskReports(taskId: string, offset: number, getAccessToken?: AccessTokenProvider) { + return reportPageSchema.parse(await operationalTaskRequest( + `/operational-tasks/${encodeURIComponent(taskId)}/reports?limit=20&offset=${offset}`, getAccessToken, + )); +} + export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) { return operationalTaskSchema.parse(await operationalTaskRequest( "/operational-tasks", diff --git a/src/features/workbench/components/agent-execution-plan-panel.test.tsx b/src/features/workbench/components/agent-execution-plan-panel.test.tsx index 67ca7af..627de21 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.test.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.test.tsx @@ -96,6 +96,23 @@ const taskBase = { work_order_snapshot: null } as OperationalTask; +it("leads with the insufficient-data report and keeps previous rounds historical", () => { + const task: OperationalTask = { ...taskBase, status: "completed", lifecycle_status: "completed", actions: [], + latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report, + stage: "final", outcome: "insufficient_data", stop_reason: "缺少末梢采样", recommendations: [], pending_items: [], + data_gaps: [{ gap_id: "gap-1", node_id: "sampling", missing_data: "末梢采样", scope: "投诉时段", + impact: "无法判断异常来源", required_data: "末梢采样结果", evidence_refs: ["e-1"], blocking: true }] } } + }; + const { rerender, ...props } = taskPanel(task); + expect(screen.getByText("本轮已结束 · 无法形成结论")).toBeVisible(); + expect(screen.getByLabelText("数据缺口")).toHaveTextContent("无法判断异常来源"); + expect(screen.queryByText("待确认事项")).not.toBeInTheDocument(); + expect(screen.queryByText("确认并创建预约工单")).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByText("本轮已结束 · 无法形成结论")).not.toBeInTheDocument(); + expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible(); +}); + function taskPanel(task: OperationalTask, overrides: Partial> = {}) { const props = { task, @@ -108,6 +125,8 @@ function taskPanel(task: OperationalTask, overrides: Partial); + const summary = screen.queryByLabelText("当前需处理")?.querySelector("summary"); + if (summary) fireEvent.click(summary); return { ...props, rerender }; } @@ -158,26 +177,87 @@ describe("operational task approval and evidence", () => { expect(container.querySelectorAll('[data-execution-node="evidence"]')).toHaveLength(1); expect(container.querySelectorAll('[data-execution-node="result"]')).toHaveLength(1); expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length); + expect(screen.getByText("汇合前置:upstream(已完成)、network(已完成)")).toBeVisible(); + }); + + it("keeps route alternatives beside the decision and expands node evidence and approvals", () => { + const actionId = taskBase.actions[0].action_id; + const plan = { + nodes: [ + { id: "risk", title: "判断风险", detail: "按投诉核对", owner: "agent" as const, status: "completed" as const, kind: "decision" as const, selected_edge_id: "urgent" }, + { id: "urgent", title: "现场采样", detail: "采样完成", owner: "human" as const, status: "completed" as const, kind: "activity" as const, evidence_refs: ["e-1"] }, + { id: "routine", title: "常规核查", detail: "未采用", owner: "human" as const, status: "skipped" as const, kind: "activity" as const }, + { id: "recheck", title: "再次采样", detail: "需复核", owner: "human" as const, status: "waiting" as const, kind: "activity" as const, revisits_node_id: "urgent" }, + { id: "approve", title: "确认工单", detail: "等待确认", owner: "human" as const, status: "waiting" as const, kind: "gate" as const, gate_ref: actionId, action_refs: [actionId] } + ], + edges: [ + { id: "urgent", from: "risk", to: "urgent", condition: "有紧急风险" }, + { id: "routine", from: "risk", to: "routine", condition: "无紧急风险" }, + { id: "next", from: "urgent", to: "recheck" }, + { id: "approval", from: "recheck", to: "approve" } + ] + }; + const { rerender } = render(); + expect(screen.getByText("其他规划路线 1 项")).toBeVisible(); + fireEvent.click(screen.getByText("其他规划路线 1 项")); + expect(screen.getByRole("region", { name: "规划路线 无紧急风险" })).toContainElement(screen.getByText("常规核查")); + fireEvent.click(screen.getByText("查看关联详情 · 1 条证据")); + expect(screen.getByText("上游余氯处于正常范围")).toBeVisible(); + fireEvent.click(screen.getAllByText("查看关联详情")[0]); + expect(screen.getByText("再次核验:现场采样")).toBeVisible(); + fireEvent.click(screen.getAllByText("查看关联详情")[1]); + expect(screen.getByText(/审核关联:动作待确认,需要现场采样/)).toBeVisible(); + expect(screen.getByText("需要现场采样 · 待确认 · 未提交")).toBeVisible(); + rerender(); + expect(screen.getByText("证据 e-1 不可用")).toBeVisible(); + expect(screen.getByText("审核关联:关联记录不可用")).toBeVisible(); + expect(screen.getByText(`动作 ${actionId} 不可用`)).toBeVisible(); + }); + + it("shows both planned routes while a decision is pending", () => { + const plan = { + nodes: [ + { id: "risk", title: "判断风险", detail: "等待资料", owner: "agent" as const, status: "waiting" as const, kind: "decision" as const, selected_edge_id: null }, + { id: "urgent", title: "紧急处置", detail: "待判断", owner: "human" as const, status: "pending" as const, kind: "activity" as const }, + { id: "routine", title: "常规核查", detail: "待判断", owner: "human" as const, status: "pending" as const, kind: "activity" as const } + ], + edges: [ + { id: "urgent", from: "risk", to: "urgent", condition: "有紧急风险" }, + { id: "routine", from: "risk", to: "routine", condition: "无紧急风险" } + ] + }; + render(); + expect(screen.getByText("待选路线 2 项")).toBeVisible(); + fireEvent.click(screen.getByText("待选路线 2 项")); + expect(screen.getByText("紧急处置")).toBeVisible(); + expect(screen.getByText("常规核查")).toBeVisible(); + expect(screen.queryByText("未选择")).not.toBeInTheDocument(); }); it("lets a reviewer edit a work-order draft before one-click dispatch", async () => { const onDraftUpdate = vi.fn(async () => undefined); const onConfirmDispatch = vi.fn(async () => undefined); const initialTask = { ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true, can_execute_work_orders: true }, - actions: [{ ...taskBase.actions[0], payload: { title: "旧草稿", description: "现场采样", work_order_type: "general", priority: "normal" } }] }; + actions: [{ ...taskBase.actions[0], payload: { title: "旧草稿", description: "现场采样", work_order_type: "water_quality_complaint", priority: "normal" } }] }; const props = taskPanel(initialTask, { onDraftUpdate, onConfirmDispatch }); + expect(screen.getByRole("heading", { name: "关联预约工单" })).toBeVisible(); + expect(screen.getByText(/调查任务只保留创建关联/)).toBeVisible(); + expect(screen.getByText("预约工单")).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "编辑草稿" })); + expect(screen.queryByRole("combobox", { name: "工单类型" })).not.toBeInTheDocument(); + expect(screen.getByText("预约任务")).toBeVisible(); fireEvent.change(screen.getByRole("textbox", { name: "工单标题" }), { target: { value: "人工修订草稿" } }); fireEvent.click(screen.getByRole("button", { name: "保存草稿" })); - await waitFor(() => expect(onDraftUpdate).toHaveBeenCalledWith(taskBase.actions[0].action_id, 3, expect.objectContaining({ title: "人工修订草稿" }))); - await waitFor(() => expect(screen.getByRole("button", { name: "确认并下发工单" })).toBeDisabled()); + await waitFor(() => expect(onDraftUpdate).toHaveBeenCalledWith(taskBase.actions[0].action_id, 3, expect.objectContaining({ title: "人工修订草稿", work_order_type: "general" }))); + await waitFor(() => expect(screen.getByRole("button", { name: "确认并创建预约工单" })).toBeDisabled()); props.rerender(); - fireEvent.click(screen.getByRole("button", { name: "确认并下发工单" })); + fireEvent.click(screen.getByRole("button", { name: "确认并创建预约工单" })); await waitFor(() => expect(onConfirmDispatch).toHaveBeenCalledWith(taskBase.actions[0].action_id, 4)); }); - it("distinguishes the current report from a previous execution without exposing generation numbers", () => { + it("distinguishes the selected report from the current investigation round", () => { taskPanel(taskBase); - expect(screen.getByText("当前结论", { exact: true })).toBeVisible(); + expect(screen.getByText("历史报告内容", { exact: true })).toBeVisible(); + expect(screen.getByText("结果未记录", { exact: true })).toBeVisible(); const previousReport = { ...taskBase, @@ -194,29 +274,31 @@ describe("operational task approval and evidence", () => { expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument(); rerender(); - expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible(); + expect(screen.getByText("本轮执行失败", { exact: true })).toBeVisible(); + expect(screen.getByText("上一轮报告", { exact: true })).toBeVisible(); expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument(); rerender(); - expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible(); + expect(screen.getByText("本轮已取消", { exact: true })).toBeVisible(); rerender(); - expect(screen.getByText("历史报告", { exact: true })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "查看新报告" })); + expect(screen.getByText(/正在阅读:历史报告/)).toBeVisible(); }); - it("labels current, previous and legacy execution plans without presenting old progress as current", () => { + it("keeps plan nodes as explanatory relationships without presenting their statuses as task progress", () => { const latestPlan: NonNullable = { revision: 1, execution_generation: 1, @@ -245,26 +327,27 @@ describe("operational task approval and evidence", () => { onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn() }; const { rerender } = render(); - expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible(); + expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible(); + expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument(); rerender(); - expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible(); + expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible(); expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument(); rerender(); - expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible(); + expect(screen.getByText("上一轮报告", { exact: true })).toBeVisible(); rerender(); - expect(screen.getByText("历史流程", { exact: true })).toBeVisible(); + expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible(); expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument(); }); @@ -279,8 +362,8 @@ describe("operational task approval and evidence", () => { expect(screen.getByText(/上游余氯处于正常范围 · SCADA 水质监测/)).toBeVisible(); expect(screen.getByText(/当前账号仅可查看/)).toBeVisible(); expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "批准创建工单" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByText("上游余氯处于正常范围")); + expect(screen.queryByRole("button", { name: "批准创建预约工单" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("主要依据").querySelector("summary")!); expect(screen.getByText("SCADA 水质监测")).toBeVisible(); expect(screen.getByText("系统数据")).toBeVisible(); }); @@ -288,7 +371,7 @@ describe("operational task approval and evidence", () => { it("keeps pending state after a decision network error", async () => { const onDecision = vi.fn(async () => { throw new Error("network unavailable"); }); taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision }); - fireEvent.click(screen.getByRole("button", { name: "批准创建工单" })); + fireEvent.click(screen.getByRole("button", { name: "批准创建预约工单" })); expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理"); expect(screen.getByText("待确认")).toBeVisible(); }); @@ -297,7 +380,7 @@ describe("operational task approval and evidence", () => { const onRefresh = vi.fn(); const onDecision = vi.fn(async () => { throw new Error("409 version conflict"); }); taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision, onRefresh }); - fireEvent.click(screen.getByRole("button", { name: "批准创建工单" })); + fireEvent.click(screen.getByRole("button", { name: "批准创建预约工单" })); const refresh = await screen.findByRole("button", { name: "刷新后重新确认" }); fireEvent.click(refresh); await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); @@ -328,6 +411,7 @@ describe("operational task approval and evidence", () => { onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile} onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()} />); + fireEvent.click(screen.getByText("现场采样 · 待核对回执")); fireEvent.click(screen.getByRole("button", { name: "核对执行回执" })); await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version)); @@ -336,7 +420,43 @@ describe("operational task approval and evidence", () => { onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile} onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()} />); - expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01"); - expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成"); + expect(screen.getByLabelText("处置进展")).toHaveTextContent("工单已创建"); + expect(screen.getByLabelText("处置进展")).toHaveTextContent("动作回执只说明对应系统操作结果"); + fireEvent.click(screen.getByText("现场采样 · 工单已创建")); + expect(screen.getByRole("region", { name: "预约工单创建回执" })).toHaveTextContent("receipt-20260923-01"); + expect(screen.getByRole("region", { name: "预约工单创建回执" })).toHaveTextContent("外围工单创建完成"); + }); + + it("orders evidence, report, approval, and receipt in one real timeline", () => { + const completedAction = { + ...taskBase.actions[0], + approval_status: "approved" as const, + execution_status: "succeeded" as const, + decided_at: "2026-09-23T01:11:00Z", + executed_at: "2026-09-23T01:12:00Z", + receipt: { + adapter: "peripheral", + receipt_id: "receipt-timeline", + submitted_at: "2026-09-23T01:11:30Z", + completed_at: "2026-09-23T01:12:00Z", + external_status: "open", + detail: "预约工单已创建,等待现场处理" + } + }; + taskPanel({ + ...taskBase, + latest_report: { ...taskBase.latest_report!, published_at: "2026-09-23T01:10:00Z" }, + actions: [completedAction] + }); + + fireEvent.click(screen.getByText("任务时间线")); + const items = [...screen.getByLabelText("任务时间线记录").querySelectorAll("li")]; + expect(items.map((item) => item.textContent)).toEqual([ + expect.stringContaining("预约工单已创建,等待现场处理"), + expect.stringContaining("现场采样已批准"), + expect.stringContaining("报告第 1 版已发布"), + expect.stringContaining("现场采样已提出"), + expect.stringContaining("上游余氯处于正常范围") + ]); }); }); diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx index ffe34ae..be31706 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -1,9 +1,12 @@ +import { InvestigationReportView } from "./investigation-report-view"; +import { InvestigationTaskReport } from "./investigation-task-report"; +import type { InvestigationDiscussion } from "../api/investigation-report"; +import type { OperationalTaskReportPage } from "../api/operational-task-client"; import { useMemo, useState, type ReactNode } from "react"; import { AlertTriangle, CheckCircle2, Clock3, - FileText, GitFork, Loader2, MessageSquare, @@ -38,8 +41,12 @@ type ExecutionNode = { selected_edge_id?: string | null; gate_ref?: string | null; revisits_node_id?: string | null; + evidence_refs?: string[]; + action_refs?: string[]; }; +type ExecutionFlowContext = Pick; + type ExecutionEdge = { id?: string | null; from: string; @@ -55,7 +62,7 @@ export type ExecutionPlanView = { type AgentExecutionPlanPanelProps = { task: OperationalTask; - onContinue: () => void; + onContinue: (discussion?: InvestigationDiscussion) => void; onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onExecute: (actionId: string, expectedVersion: number) => Promise; onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -64,6 +71,9 @@ type AgentExecutionPlanPanelProps = { onReconcile: (actionId: string, expectedVersion: number) => Promise; onLoadEvidenceSnapshot: (evidenceId: string) => Promise; onRefresh: () => void; + onLoadReports?: (offset: number) => Promise; + onReportViewed?: (generation: number, revision: number) => void; + focusActionId?: string | null; }; export function AgentExecutionPlanPanel({ @@ -76,8 +86,17 @@ export function AgentExecutionPlanPanel({ onAccessDecision, onReconcile, onLoadEvidenceSnapshot, - onRefresh + onRefresh, + onLoadReports, + onReportViewed, + focusActionId }: AgentExecutionPlanPanelProps) { + if (task.task_type === "event_investigation") return } + renderEvidence={(item) => } + />; const plan = task.latest_plan?.plan; const report = task.latest_report?.report; const planGeneration = task.latest_plan?.execution_generation; @@ -92,9 +111,19 @@ export function AgentExecutionPlanPanel({ : reportGeneration === task.execution_generation ? "current" : "previous"; - const completed = plan?.nodes.filter((node) => node.status === "completed").length ?? 0; - const active = plan?.nodes.filter((node) => node.status === "running").length ?? 0; - const waiting = plan?.nodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length ?? 0; + const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2" + ? activePlanGraph(plan).visible : null; + const visiblePlanNodes = plan && visiblePlanNodeIds + ? plan.nodes.filter((node) => visiblePlanNodeIds.has(node.id)) + : plan?.nodes ?? []; + const completed = visiblePlanNodes.filter((node) => node.status === "completed").length; + const active = visiblePlanNodes.filter((node) => node.status === "running").length; + const waiting = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length; + const selectedRoutes = visiblePlanNodes.filter((node) => node.kind === "decision" && node.selected_edge_id).map((node) => { + const edge = plan?.edges.find((item) => item.id === node.selected_edge_id && item.from === node.id); + return edge ? `${node.title}:${edge.condition}` : null; + }).filter((item): item is string => Boolean(item)); + const waitingNodes = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").map((node) => node.title); const externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback"); const taskActive = task.status === "queued" || task.status === "running"; const planSummary = !plan @@ -123,6 +152,7 @@ export function AgentExecutionPlanPanel({ return (
+
@@ -130,8 +160,8 @@ export function AgentExecutionPlanPanel({

@@ -144,7 +174,15 @@ export function AgentExecutionPlanPanel({ {task.task_type === "external_work_order" && externalEvidence.length > 0 ? ( ) : plan ? ( - + <> +

+

{plan.title} · 第 {task.latest_plan?.revision} 版

+

规划依据:{plan.reason}

+ {plan.schema === "execution-plan@2" ?

{selectedRoutes.length ? `实际路线:${selectedRoutes.join(";")}` : plan.nodes.some((node) => node.kind === "decision") ? "实际路线:待判断" : "实际路线:按计划执行"}

: null} + {waitingNodes.length > 0 ?

等待:{waitingNodes.join("、")}

: null} +
+ + ) : (

尚无执行节点,页面不会按时间推算或虚构完成进度。

)} @@ -156,38 +194,6 @@ export function AgentExecutionPlanPanel({ canDecide={task.permissions.can_decide_access} onDecision={onAccessDecision} onRefresh={onRefresh} />)}
: null} -
-
-

-

结论与证据独立呈现,不展示模型推理或节点执行日志。

-
- {report ? ( - <> -
-

- {reportLabel} -

-

{report.current_conclusion}

-
- {report.recommendations.length > 0 ? ( - - ) : null} - {report.pending_items.length > 0 ? ( - - ) : null} -

结论边界:{report.conclusion_boundary}

- - ) : ( -

尚未形成任务报告。

- )} -
- {unlinkedActions.length > 0 ? ( @@ -209,7 +215,7 @@ export function AgentExecutionPlanPanel({ ) : null}
-
@@ -217,60 +223,58 @@ export function AgentExecutionPlanPanel({ ); } -function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) { - return ( -
-

{title}

-
    - {items.map((item) => ( -
  • -
  • - ))} -
-
- ); -} - -export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record }) { +export function ExecutionPlanSteps({ plan, nodeActions = {}, context, onLoadEvidenceSnapshot }: { + plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; + nodeActions?: Record; + context?: ExecutionFlowContext; + onLoadEvidenceSnapshot?: (evidenceId: string) => Promise; +}) { return
{ plan.nodes.some((node) => node.kind) - ? + ? : }
; } -function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record }) { +type SemanticFlowProps = { + plan: ExecutionPlanView; + nodeActions: Record; + context?: ExecutionFlowContext; + onLoadEvidenceSnapshot?: (evidenceId: string) => Promise; +}; + +function SemanticExecutionFlow({ plan, nodeActions, context, onLoadEvidenceSnapshot }: SemanticFlowProps) { + const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); + const { activeEdges, visible } = activePlanGraph(plan); + const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)); + return
+
+ {roots.filter((node) => visible.has(node.id)).map((node) => )} +
+
; +} + +function activePlanGraph(plan: ExecutionPlanView) { const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); const activeEdges = plan.edges.filter((edge) => { const source = nodeById.get(edge.from); return source?.kind !== "decision" || source.selected_edge_id === edge.id; }); - const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)); const visible = new Set(); - const queue = roots.map((node) => node.id); + const queue = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)).map((node) => node.id); while (queue.length > 0) { const current = queue.shift()!; if (visible.has(current)) continue; visible.add(current); for (const edge of activeEdges.filter((item) => item.from === current)) queue.push(edge.to); } - const ordered = topologicalNodes(plan); - const alternatives = ordered.filter((node) => !visible.has(node.id)); - return
-
- {roots.filter((node) => visible.has(node.id)).map((node) => )} -
- {alternatives.length > 0 ? -
    {alternatives.map((node) =>
  1. )}
-
: null} -
; + return { activeEdges, visible }; } -function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: { +function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen, visible, context, onLoadEvidenceSnapshot }: { nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[]; - nodeById: Map; nodeActions: Record; seen: Set; + nodeById: Map; nodeActions: Record; seen: Set; visible: Set; + context?: ExecutionFlowContext; onLoadEvidenceSnapshot?: (evidenceId: string) => Promise; }) { if (nodeId === stopId || seen.has(nodeId)) return null; const node = nodeById.get(nodeId); @@ -280,20 +284,36 @@ function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions const allRoutes = plan.edges.filter((edge) => edge.from === nodeId); const join = node.kind === "parallel_split" ? findSemanticJoin(outgoing, activeEdges, nodeById, stopId) : null; const branchEnd = join?.id ?? stopId; + const selectedRoute = node.kind === "decision" ? allRoutes.find((edge) => edge.id === node.selected_edge_id) : null; + const unchosenRoutes = node.kind === "decision" ? allRoutes.filter((edge) => edge.id !== node.selected_edge_id) : []; + const claimed = new Set(); + const alternativeGroups = unchosenRoutes.map((edge) => { + const reachable = reachableFrom(edge.to, new Map(plan.nodes.map((item) => [item.id, plan.edges.filter((route) => route.from === item.id)]))); + const nodes = topologicalNodes(plan).filter((item) => reachable.has(item.id) && !visible.has(item.id) && !claimed.has(item.id)); + nodes.forEach((item) => claimed.add(item.id)); + return { edge, nodes }; + }); + const predecessors = node.kind === "parallel_join" ? plan.edges.filter((edge) => edge.to === node.id).map((edge) => nodeById.get(edge.from)).filter((item): item is ExecutionNode => Boolean(item)) : []; return
- + {node.kind === "decision" ?
- 路线选择:{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"} -
{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}
+ 路线选择:{selectedRoute?.condition ?? selectedRoute?.label ?? "等待判断"} + {alternativeGroups.length > 0 ? +
{alternativeGroups.map(({ edge, nodes }) =>
+

{edge.condition ?? edge.label ?? "未命名路线"} · {selectedRoute ? "未选择" : "待判断"}

+ {nodes.length > 0 ?
    {nodes.map((item) =>
  1. )}
:

此路线在后续节点汇合。

} +
)}
+
: null}
: null} + {predecessors.length > 0 ?

汇合前置:{predecessors.map((item) => `${item.title}(${executionNodePresentation(item).label})`).join("、")}

: null} {node.kind === "parallel_split" ?

-
{outgoing.map((edge, index) =>
+
{outgoing.map((edge, index) =>

分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}

- +
)}
-
: outgoing[0] ? : null} - {join ? : null} +
: outgoing[0] ? : null} + {join ? : null}
; } @@ -315,19 +335,36 @@ function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; n })}; } -function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) { +function ExecutionNodeCard({ node, action, compact = false, context, onLoadEvidenceSnapshot, nodeById }: { + node: ExecutionNode; action?: ReactNode; compact?: boolean; context?: ExecutionFlowContext; + onLoadEvidenceSnapshot?: (evidenceId: string) => Promise; + nodeById?: Map; +}) { const { tone, Icon, label } = executionNodePresentation(node); + const evidence = node.evidence_refs?.map((id) => context?.evidence.find((item) => item.evidence_id === id) ?? id) ?? []; + const gate = node.gate_ref && context ? context.access_requests.find((item) => item.access_request_id === node.gate_ref) ?? context.actions.find((item) => item.action_id === node.gate_ref) : null; + const relatedActions = node.action_refs?.map((id) => context?.actions.find((item) => item.action_id === id) ?? id) ?? []; + const hasDetails = Boolean(node.kind && (evidence.length || node.gate_ref || node.revisits_node_id || relatedActions.length)); return (
{node.title}{label}
- {node.kind && node.kind !== "activity" ?

{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}

: null} + {node.kind && node.kind !== "activity" ?

{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "审核关口" }[node.kind]}

: null} + {node.kind ?

负责:{node.owner === "human" ? "人工" : "Agent"}

: null}

{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}

- {action ?
{action}
: null} + {hasDetails ? +
+ {node.revisits_node_id ?

再次核验:{nodeById?.get(node.revisits_node_id)?.title ?? `节点 ${node.revisits_node_id}`}

: null} + {evidence.length > 0 ?

节点依据

{evidence.map((item) => typeof item === "string" ?

证据 {item} 不可用

: onLoadEvidenceSnapshot ? :

{item.summary} · {item.source_label}

)}
: null} + {node.gate_ref ?

审核关联:{gate ? "scopes" in gate ? `数据访问${gate.status === "approved" ? "已批准" : gate.status === "rejected" ? "已拒绝" : "待审核"},${gate.reason}` : `动作${approvalLabel(gate.approval_status)},${gate.reason}` : "关联记录不可用"}

: null} + {relatedActions.length > 0 ?

关联动作

{relatedActions.map((item) =>

{typeof item === "string" ? `动作 ${item} 不可用` : `${item.reason} · ${approvalLabel(item.approval_status)} · ${executionLabel(item.execution_status)}`}

)}
: null} +
+
: null}
+ {action ?
{action}
: null}
); } @@ -448,8 +485,10 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp const [rejectComment, setRejectComment] = useState(""); const [editing, setEditing] = useState(false); const [savedVersion, setSavedVersion] = useState(null); - const [draft, setDraft] = useState>(action.payload); const command = action.kind === "issue_command"; + const linkedAppointment = !command && task.task_type === "event_investigation"; + const [draft, setDraft] = useState>(() => workOrderDraft(action.payload)); + const displayedPayload = command ? action.payload : workOrderDraft(action.payload); const canDecide = command ? task.permissions.can_decide_commands : task.permissions.can_decide_work_orders; const canExecute = command ? task.permissions.can_execute_commands : task.permissions.can_execute_work_orders; const run = async (kind: "approve" | "reject" | "execute" | "reconcile" | "save" | "confirm") => { @@ -457,7 +496,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp try { if (kind === "save") { if (!onDraftUpdate) throw new Error("工单草稿编辑不可用"); - await onDraftUpdate(action.action_id, action.version, draft); + await onDraftUpdate(action.action_id, action.version, workOrderDraft(draft)); setSavedVersion(action.version); setEditing(false); } else if (kind === "confirm") { @@ -483,17 +522,19 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
-

{command ? "命令下发确认" : "工单创建确认"}

{approvalLabel(action.approval_status)}{executionLabel(action.execution_status)}
+

{command ? "命令下发确认" : linkedAppointment ? "关联预约工单" : "预约工单创建确认"}

{approvalLabel(action.approval_status)}{executionLabel(action.execution_status)}

原因:{action.reason}

- + {linkedAppointment ?

调查任务只保留创建关联。创建后请到“预约任务”查看派发进度、现场反馈和复令。

: null} + {editing && !command ?
setDraft({ ...draft, title: event.target.value })} /> setDraft({ ...draft, description: event.target.value })} /> -
+ + setDraft({ ...draft, location: event.target.value || null })} /> setDraft({ ...draft, assignee: event.target.value || null })} /> setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} /> -
+
: null} {action.evidence_refs.length > 0 ? (
@@ -506,7 +547,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
) : null} - {action.receipt ? : null} + {action.receipt ? : null} {action.execution_detail && !action.receipt ?

{action.execution_detail}

: null}
{JSON.stringify(action.payload, null, 2)}

确认版本 v{action.version}。内容或版本变化后必须重新确认。

@@ -515,8 +556,8 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp {!canDecide && action.approval_status === "pending" ?

当前账号仅可查看,需要具备此动作审批权限的人员确认。

: null} {action.approval_status === "pending" && canDecide ? setRejectComment(event.target.value)} /> : null}
- {action.approval_status === "pending" && canDecide ? <>{!command && onDraftUpdate ? : null} : null} - {action.approval_status === "approved" && action.execution_status === "not_submitted" ? : null} + {action.approval_status === "pending" && canDecide ? <>{!command && onDraftUpdate ? : null} : null} + {action.approval_status === "approved" && action.execution_status === "not_submitted" ? : null} {action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? : null}
@@ -525,9 +566,9 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp ); } -function ActionReceipt({ receipt }: { receipt: NonNullable }) { - return
-

执行回执

+function ActionReceipt({ receipt, title = "执行回执" }: { receipt: NonNullable; title?: string }) { + return
+

{title}

@@ -548,6 +589,10 @@ function ActionPayloadSummary({ payload }: { payload: Record }) return
{rows.map((row) =>
{payloadLabel(row.key)}
{payloadValue(row.key, row.value)}
)}
; } +function workOrderDraft(payload: Record) { + return { ...payload, work_order_type: "general" }; +} + function payloadLabel(key: string) { const labels: Record = { title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级", @@ -562,7 +607,7 @@ function payloadValue(key: string, value: unknown) { if (value === null || value === undefined || value === "") return "未指定"; if (key === "command_type") return knownLabel(value, { set_valve: "调节阀门" }); if (key === "target.kind") return knownLabel(value, { valve: "阀门", pump: "泵站", pipe: "管段" }); - if (key === "work_order_type") return knownLabel(value, { general: "一般工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" }); + if (key === "work_order_type") return knownLabel(value, { general: "预约工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" }); if (key === "priority") return knownLabel(value, { normal: "普通", urgent: "紧急", high: "高", low: "低" }); if (key === "scheduled_at" || key === "expires_at") return typeof value === "string" ? formatDateTime(value) : String(value); if (Array.isArray(value)) return value.map(String).join("、") || "无"; @@ -605,7 +650,7 @@ function topologicalNodes(plan: ExecutionPlanView) { function executionNodePresentation(node: ExecutionNode) { const tone: StatusTone = node.status === "completed" ? "success" : node.status === "running" ? "info" : node.status === "failed" ? "danger" : node.status === "waiting" ? "warning" : "neutral"; const Icon = node.status === "completed" ? CheckCircle2 : node.status === "running" ? Loader2 : node.status === "waiting" || node.status === "failed" ? AlertTriangle : Clock3; - const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "不适用", cancelled: "已取消" } as const; + const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "未进入", cancelled: "已取消" } as const; return { tone, Icon, label: labels[node.status] }; } diff --git a/src/features/workbench/components/investigation-diagram-view.tsx b/src/features/workbench/components/investigation-diagram-view.tsx new file mode 100644 index 0000000..d60d013 --- /dev/null +++ b/src/features/workbench/components/investigation-diagram-view.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react"; +import { GitBranch, Maximize2, RotateCcw, X, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; +import { MessageResponse } from "@/shared/ai-elements/message"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/ui/cn"; +import type { InvestigationDiagram } from "../api/investigation-report"; +import { + INVESTIGATION_DIAGRAM_FONT, + investigationDiagramConfig, + investigationDiagramPlugins, + styleInvestigationDiagram +} from "../utils/investigation-diagram-style"; + +function controlIcon(Icon: LucideIcon, control: string) { + return function DiagramControlIcon({ className, ...props }: SVGProps & { size?: number }) { + return ( + + ); + }; +} + +// The icon markers also identify controls in Streamdown's fullscreen portal. +const diagramControlIcons = { + ZoomInIcon: controlIcon(ZoomIn, "zoom-in"), + ZoomOutIcon: controlIcon(ZoomOut, "zoom-out"), + RotateCcwIcon: controlIcon(RotateCcw, "reset"), + Maximize2Icon: controlIcon(Maximize2, "fullscreen"), + XIcon: controlIcon(X, "close") +}; + +function DiagramUnavailable() { + return ( +

+ 图示暂不可用,仍可查看结论、证据和待审核动作。 +

+ ); +} + +const getNodeId = (node: Element | null | undefined) => + node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1]; + +export function InvestigationDiagramView({ + diagram, + renderDetails, + onAsk +}: { + diagram: InvestigationDiagram; + renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode; + onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void; +}) { + const [selectedNodeId, setSelectedNodeId] = useState(null); + const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null); + const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId); + const [fontsReady, setFontsReady] = useState( + () => typeof document === "undefined" || !document.fonts + ); + useEffect(() => { + if (!document.fonts) return; + let active = true; + void document.fonts + .load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid) + .catch(() => undefined) + .then(() => { + if (active) setFontsReady(true); + }); + return () => { + active = false; + }; + }, [diagram.mermaid]); + return ( +
+

+ + {diagram.title} +

+

+ 核查方向与判断分支。可放大查看,点击节点阅读关联依据。 +

+
{ + const node = (event.target as Element).closest?.(".node"); + const ref = diagram.node_refs.find((item) => getNodeId(node) === item.node_id); + pointerStart.current = ref + ? { nodeId: ref.node_id, x: event.clientX, y: event.clientY } + : null; + }} + onClick={(event) => { + const node = (event.target as Element).closest?.(".node"); + // The pan control captures pointers; retain the hit target for a click, while ignoring drags. + const start = pointerStart.current; + pointerStart.current = null; + if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) > 6) return; + const ref = diagram.node_refs.find( + (item) => getNodeId(node) === item.node_id || start?.nodeId === item.node_id + ); + if (ref) setSelectedNodeId(ref.node_id); + }} + > + {fontsReady ? ( + {`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``} + ) : ( +

正在排版调查图示。

+ )} +
+ {diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? ( +
+ {diagram.node_refs.some( + (node) => node.evidence_refs.length && !node.action_refs.length + ) ? ( + + + 引用证据 + + ) : null} + {diagram.node_refs.some((node) => node.action_refs.length) ? ( + + + 关联业务动作 + + ) : null} +
+ ) : null} + {diagram.node_refs.length > 0 ? ( +
+ {diagram.node_refs.map((node) => ( + + ))} +
+ ) : null} + {selected ? ( +
+

{selected.label}

+ {renderDetails(selected)} + {onAsk ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/features/workbench/components/investigation-report-view.tsx b/src/features/workbench/components/investigation-report-view.tsx new file mode 100644 index 0000000..e43b8ca --- /dev/null +++ b/src/features/workbench/components/investigation-report-view.tsx @@ -0,0 +1,55 @@ +import { FileText } from "lucide-react"; +import { StatusBadge } from "@/shared/ui/status"; +import type { OperationalTask } from "../api/operational-task-client"; + +export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) { + const snapshot = task.latest_report; + const report = snapshot?.report; + const insufficient = report?.outcome === "insufficient_data"; + const current = snapshot?.execution_generation === task.execution_generation; + const ended = current && task.lifecycle_status === "completed"; + const insufficientLabel = ended ? "本轮已结束 · 无法形成结论" + : task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败" + : task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消" + : "无法形成结论 · 正在结束本轮"; + return
+
+

+ {report ? + {insufficient ? "证据不足" : report.outcome === "concluded" ? "已形成结论" : "结果未记录"} + : null} +
+ {report ? <> +
+

+ {!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"} +

+

{report.current_conclusion}

+ {insufficient ?

结束原因:{report.stop_reason}

: null} +
+ {report.data_gaps && report.data_gaps.length > 0 ?
+

{insufficient ? "关键数据缺口" : "数据范围限制"}

+ {report.data_gaps.map((gap) =>
+

{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}

+

查询范围:{gap.scope}

+

判断影响:{gap.impact}

+

需补齐:{gap.required_data}

+

依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}

+
)} +
: report.data_gaps == null ?

历史记录未提供结构化数据缺口。

: null} + {!insufficient && report.recommendations.length > 0 ? : null} + {!insufficient && report.pending_items.length > 0 ? : null} + {insufficient ?

补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。

: null} +
+

结论边界:{report.conclusion_boundary}

+

报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}

+
+ :

尚未形成任务报告。

} +
; +} + +function ReportItems({ title, items }: { title: string; items: string[] }) { + return

{title}

+
    {items.map((item, index) =>
  • {item}
  • )}
+
; +} diff --git a/src/features/workbench/components/investigation-task-report.tsx b/src/features/workbench/components/investigation-task-report.tsx new file mode 100644 index 0000000..0b3a95d --- /dev/null +++ b/src/features/workbench/components/investigation-task-report.tsx @@ -0,0 +1,634 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { FileText, History, MessageSquare } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Disclosure } from "@/shared/ui/disclosure"; +import { StatusBadge } from "@/shared/ui/status"; +import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report"; +import type { + OperationalTask, + OperationalTaskAction, + OperationalTaskEvidence, + OperationalTaskReport, + OperationalTaskReportPage +} from "../api/operational-task-client"; +import { + investigationDiagram, + investigationStatus, + isPendingInvestigationAction +} from "../utils/investigation-presentation"; +import { InvestigationDiagramView } from "./investigation-diagram-view"; + +type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null }; +type Props = { + task: OperationalTask; + onContinue: (discussion?: InvestigationDiscussion) => void; + renderAction: (action: OperationalTaskAction) => ReactNode; + renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode; + onLoadReports?: (offset: number) => Promise; + onReportViewed?: (generation: number, revision: number) => void; + focusActionId?: string | null; +}; + +const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false }); +const roundLabel = (snapshot: OperationalTaskReport) => + snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`; +const actionTitle = (action: OperationalTaskAction) => + String(action.payload.title ?? (action.kind === "issue_command" ? "设备命令" : "工单草稿")); +const actionProgressLabel = (action: OperationalTaskAction) => { + if (action.approval_status === "rejected") return "已拒绝"; + if (action.approval_status === "pending") return "待审核"; + return { + not_submitted: "待执行", + submitted: "已提交", + succeeded: action.kind === "create_work_order" ? "工单已创建" : "执行成功", + failed: "执行失败", + unsupported: "无法执行", + unknown: "待核对回执" + }[action.execution_status]; +}; + +type InvestigationTimelineItem = { + id: string; + occurredAt: string; + title: string; + detail: string; +}; + +function investigationTimeline( + task: OperationalTask, + snapshot: OperationalTaskReport | null +): InvestigationTimelineItem[] { + const items: InvestigationTimelineItem[] = task.evidence.map((evidence) => ({ + id: `evidence:${evidence.evidence_id}`, + occurredAt: evidence.recorded_at, + title: evidence.kind === "human_input" ? "已记录人工事实" : "已保存调查资料", + detail: evidence.summary + })); + if (snapshot) { + items.push({ + id: `report:${snapshot.revision}`, + occurredAt: snapshot.published_at, + title: `${roundLabel(snapshot)}报告第 ${snapshot.revision} 版已发布`, + detail: snapshot.report.current_conclusion + }); + } + task.actions.forEach((action) => { + const title = actionTitle(action); + items.push({ + id: `action:${action.action_id}:proposed`, + occurredAt: action.proposed_at, + title: `${title}已提出`, + detail: action.reason + }); + if (action.edited_at) { + items.push({ + id: `action:${action.action_id}:edited`, + occurredAt: action.edited_at, + title: `${title}草稿已更新`, + detail: "后续审核与执行以更新后的版本为准。" + }); + } + if (action.decided_at && action.approval_status !== "pending") { + items.push({ + id: `action:${action.action_id}:decided`, + occurredAt: action.decided_at, + title: `${title}${action.approval_status === "approved" ? "已批准" : "已拒绝"}`, + detail: action.decision_comment ?? "已记录人工审核结果。" + }); + } + if (action.receipt) { + items.push({ + id: `action:${action.action_id}:receipt`, + occurredAt: + action.receipt.completed_at ?? action.executed_at ?? action.receipt.submitted_at, + title: `${title}已有执行回执`, + detail: action.receipt.detail + }); + } else if (action.executed_at) { + items.push({ + id: `action:${action.action_id}:executed`, + occurredAt: action.executed_at, + title: `${title}${actionProgressLabel(action)}`, + detail: action.execution_detail ?? "已记录动作执行状态。" + }); + } + }); + return items.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt)); +} + +export function InvestigationTaskReport({ + task, + onContinue, + renderAction, + renderEvidence, + onLoadReports, + onReportViewed, + focusActionId +}: Props) { + const [selection, setSelection] = useState(() => + task.latest_report + ? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) } + : null + ); + const [history, setHistory] = useState([]); + const [historyOffset, setHistoryOffset] = useState(0); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const snapshot = selection?.snapshot ?? null; + const report = snapshot?.report; + const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null)); + const previousRound = Boolean( + snapshot && snapshot.execution_generation !== task.execution_generation + ); + const olderRevision = Boolean( + snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision + ); + const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision)); + const insufficient = report?.outcome === "insufficient_data"; + const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation + && task.latest_report?.report.outcome === "insufficient_data"; + const pendingActions = task.actions.filter(isPendingInvestigationAction); + const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action)); + const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id)); + const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3); + const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3); + const timeline = investigationTimeline(task, snapshot); + + useEffect(() => { + if (!selection && task.latest_report) + setSelection({ + snapshot: task.latest_report, + diagram: investigationDiagram(task, task.latest_report) + }); + }, [selection, task]); + + useEffect(() => { + if (snapshot?.execution_generation == null || !onReportViewed) return; + onReportViewed(snapshot.execution_generation, snapshot.revision); + }, [snapshot, onReportViewed]); + + useEffect(() => { + if (!focusActionId) return; + const details = [ + ...document.querySelectorAll("details[data-review-action]") + ].find((item) => item.dataset.reviewAction === focusActionId); + if (details) { + details.open = true; + details.scrollIntoView?.({ block: "nearest" }); + details.querySelector("summary")?.focus(); + } + }, [focusActionId]); + + const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) => + onContinue({ + prompt, + context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra } + }); + const selectReport = (item: OperationalTaskReport) => + setSelection({ snapshot: item, diagram: investigationDiagram(task, item) }); + const loadHistory = async (offset: number) => { + if (!onLoadReports || historyLoading) return; + setHistoryLoading(true); + setHistoryError(null); + try { + const page = await onLoadReports(offset); + setHistory((current) => (offset === 0 ? page.items : [...current, ...page.items])); + setHistoryOffset(page.next_offset); + } catch (error) { + setHistoryError(error instanceof Error ? error.message : "报告版本加载失败"); + } finally { + setHistoryLoading(false); + } + }; + + return ( +
+
+
+

+ 调查任务 · 第 {task.execution_generation} 轮 +

+
+

{task.title}

+ + {investigationStatus(task)} + +
+

{task.summary}

+
+ +
+ + {latestAvailable ? ( +
+ 第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告 + +
+ ) : null} + +
+
+

+ + 当前结果 +

+ {report ? ( + + {insufficient + ? "证据不足" + : report.outcome === "concluded" + ? "已形成判断" + : "结果未记录"} + + ) : null} +
+ {report && snapshot ? ( + <> +

+ {previousRound + ? task.status === "queued" || task.status === "running" + ? "上一轮报告,本轮分析中" + : "上一轮报告" + : olderRevision + ? "本轮历史报告" + : insufficient + ? task.lifecycle_status === "completed" + ? "本轮已结束 · 无法形成结论" + : task.lifecycle_status === "failed" || task.lifecycle_status === "cancelled" + ? "无法形成结论 · 本轮运行已停止" + : "无法形成结论 · 正在结束本轮" + : report.stage === "interim" + ? "阶段性判断" + : report.outcome === "concluded" + ? "调查结论" + : "历史报告内容"} +

+

{report.current_conclusion}

+

+ 结论边界:{report.conclusion_boundary} +

+ {insufficient ? ( +

+ 结束原因:{report.stop_reason} +

+ ) : null} +

+ 正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "} + {dateLabel(snapshot.published_at)} +

+ {!insufficient && + (report.recommendations.length > 0 || report.pending_items.length > 0) ? ( + + {report.recommendations.length > 0 ? ( + + ) : null} + {report.pending_items.length > 0 ? ( + + ) : null} + + ) : null} + + ) : ( +

+ 尚未发布本轮调查报告。已保存的调查资料可在下方查看。 +

+ )} +
+ + {pendingActions.length > 0 ? ( +
+

+ 当前需处理{" "} + + · {pendingActions.length} 项 + +

+ {previousRound || olderRevision ? ( +

+ 下列操作使用当前草稿与审批状态。 +

+ ) : null} + {pendingActions.map((action) => ( + + {actionTitle(action)} ·{" "} + {action.approval_status === "pending" + ? "待审核" + : action.execution_status === "not_submitted" + ? "待执行" + : "待核对回执"} + + } + > + {renderAction(action)} + + ))} +
+ ) : null} + + {completedActions.length > 0 ? ( +
+

+ 处置进展{" "} + + · {completedActions.length} 项 + +

+

+ {previousRound || olderRevision + ? "以下为任务当前动作状态,与所选历史报告分开记录。" + : "动作回执只说明对应系统操作结果,不替代后续现场事实。"} +

+ {completedActions.map((action) => ( + + {actionTitle(action)} · {actionProgressLabel(action)} + + {action.receipt?.detail ?? action.execution_detail ?? action.reason} + + + } + > +
{renderAction(action)}
+
+ ))} +
+ ) : null} + + {report?.data_gaps?.length ? ( +
+

+ {insufficient ? "关键数据缺口" : "数据范围限制"} +

+ {report.data_gaps.map((gap) => ( +
+

{gap.missing_data}

+

判断影响:{gap.impact}

+
+ +
+

查询范围:{gap.scope}

+

需补齐:{gap.required_data}

+ {gap.evidence_refs.map((id) => ( + + ))} +
+
+ +
+
+ ))} + {insufficient ? ( +

+ {previousRound || olderRevision + ? "此处展示所选历史报告的数据缺口,当前调查状态见顶部。" + : "补齐事实后发起新一轮调查。原投诉工单与现场处置进度独立记录。"} +

+ ) : null} +
+ ) : report?.data_gaps == null && report ? ( +

历史记录未提供结构化数据缺口。

+ ) : null} + + {diagram ? ( + ( + <> + {node.evidence_refs.map((id) => ( + + ))} + {node.action_refs.map((id) => { + const action = task.actions.find((item) => item.action_id === id); + return action ? ( +

+ 关联动作:{actionTitle(action)} ·{" "} + {action.approval_status === "approved" + ? "已批准" + : action.approval_status === "rejected" + ? "已拒绝" + : "待审核"}{" "} + ·{" "} + {action.execution_status === "succeeded" + ? "已有执行回执" + : "当前执行结果未确认成功"} +

+ ) : null; + })} + {!node.evidence_refs.length && !node.action_refs.length ? ( +

+ 此处说明核查方向,尚无关联证据或业务动作记录。 +

+ ) : null} + + )} + onAsk={(node) => + discuss( + `请解释调查脉络中的“${node.label}”及其关联依据。`, + node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined + ) + } + /> + ) : null} + +
+

主要依据

+ {majorEvidence.map((item) => ( +
+ {renderEvidence(item)} + +
+ ))} + {(snapshot ? evidence : task.evidence).length === 0 ? ( +

尚无可展示的引用证据。

+ ) : null} + {remainingEvidence.length > 0 ? ( + + {remainingEvidence.map((item) => ( +
+ {renderEvidence(item)} + +
+ ))} +
+ ) : null} + {snapshot && + task.evidence.some((item) => !report?.evidence_refs.includes(item.evidence_id)) ? ( + + {task.evidence + .filter((item) => !report?.evidence_refs.includes(item.evidence_id)) + .map(renderEvidence)} + + ) : null} +
+ +
+ +

+ 按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。 +

+
    + {timeline.map((item) => ( +
  1. + +

    {item.title}

    +

    {item.detail}

    +
  2. + ))} +
+
+ {onLoadReports ? ( + + + 报告版本 + + } + onToggle={(event) => { + if (event.currentTarget.open && history.length === 0 && !historyLoading) + void loadHistory(0); + }} + > + {history.map((item) => ( + + ))} + {historyError ? ( +

+ {historyError} +

+ ) : null} + {historyLoading ? ( +

正在读取报告版本。

+ ) : historyOffset != null || historyError ? ( + + ) : null} +
+ ) : null} +

+ 任务更新于 {dateLabel(task.updated_at)} +

+
+
+ ); +} + +function EvidenceReference({ + id, + task, + renderEvidence +}: { + id: string; + task: OperationalTask; + renderEvidence: Props["renderEvidence"]; +}) { + const item = task.evidence.find((evidence) => evidence.evidence_id === id); + return item ? ( + renderEvidence(item) + ) : ( +

关联证据当前不可用。

+ ); +} + +function ReportItems({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); +} diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 57b1842..51dbfa9 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -111,7 +111,7 @@ type ConditionDetailPanelProps = { condition: ScheduledConditionItem; dataSource?: WorkbenchRuntimeDataSource; workOrderDataSource?: WorkbenchWorkOrderDataSource; - onContinueConversation: () => void; + onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => void; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -120,6 +120,9 @@ type ConditionDetailPanelProps = { onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; onTaskRefresh?: () => void; + onLoadTaskReports?: (taskId: string, offset: number) => Promise; + onReportViewed?: (taskId: string, generation: number, revision: number) => void; + focusActionId?: string | null; }; export function StatusPill({ @@ -130,6 +133,10 @@ export function StatusPill({ className?: string; }) { const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint"; + const insufficient = condition.kind === "condition" + && condition.operationalTask?.lifecycle_status === "completed" + && condition.operationalTask.latest_report?.execution_generation === condition.operationalTask.execution_generation + && condition.operationalTask.latest_report?.report.outcome === "insufficient_data"; const externalStatus = condition.kind === "work_order" ? condition.externalStatus : condition.operationalTask?.task_type === "external_work_order" @@ -141,14 +148,14 @@ export function StatusPill({ return ( - {isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]} + {insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]} ); } @@ -165,7 +172,10 @@ export function ConditionDetailPanel({ onTaskAccessDecision, onTaskReconcile, onLoadTaskEvidenceSnapshot, - onTaskRefresh + onTaskRefresh, + onLoadTaskReports, + onReportViewed, + focusActionId }: ConditionDetailPanelProps) { const detailKey = condition.kind === "work_order" @@ -190,6 +200,9 @@ export function ConditionDetailPanel({ onTaskReconcile={onTaskReconcile} onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot} onTaskRefresh={onTaskRefresh} + onLoadTaskReports={onLoadTaskReports} + onReportViewed={onReportViewed} + focusActionId={focusActionId} /> ); @@ -221,12 +234,15 @@ function ConditionRecordDetailPanel({ onTaskAccessDecision, onTaskReconcile, onLoadTaskEvidenceSnapshot, - onTaskRefresh + onTaskRefresh, + onLoadTaskReports, + onReportViewed, + focusActionId }: { condition: ScheduledConditionRecord; dataSource: WorkbenchRuntimeDataSource; workOrderDataSource: WorkbenchWorkOrderDataSource; - onContinueConversation: () => void; + onContinueConversation: ConditionDetailPanelProps["onContinueConversation"]; onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"]; onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"]; onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"]; @@ -235,13 +251,17 @@ function ConditionRecordDetailPanel({ onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"]; onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"]; onTaskRefresh?: () => void; + onLoadTaskReports?: ConditionDetailPanelProps["onLoadTaskReports"]; + onReportViewed?: ConditionDetailPanelProps["onReportViewed"]; + focusActionId?: string | null; }) { const backendCondition = (condition.dataSource ?? dataSource) !== "mock"; const insight = backendCondition ? null : getConditionAnalysisInsight(condition); const [submittedWorkOrder, setSubmittedWorkOrder] = useState(null); const header = getConditionDetailHeader(condition); const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel); - const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`; + const investigation = condition.operationalTask?.task_type === "event_investigation"; + const bodyKey = investigation ? condition.id : `${condition.id}-${condition.status}-${condition.riskLevel}`; const externalWorkOrder = condition.operationalTask?.work_order_snapshot ? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot) : null; @@ -260,13 +280,13 @@ function ConditionRecordDetailPanel({ return ( - + /> : null} onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined} + onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined} + focusActionId={focusActionId} onContinue={onContinueConversation} onDecision={(actionId, version, decision, comment) => { if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。")); diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index e7ff2ec..9fb8164 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -19,6 +19,8 @@ import type { } from "../types"; import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts"; import type { OperationalTaskDecision } from "../api/operational-task-client"; +import type { InvestigationDiscussion } from "../api/investigation-report"; +import type { OperationalTaskReportPage } from "../api/operational-task-client"; import type { OperationalTaskDataSnapshot } from "../api/operational-task-client"; import { cn } from "@/shared/ui/cn"; import { @@ -82,7 +84,7 @@ type ScheduledConditionFeedProps = { onClose?: () => void; onLoadHistorySession: (sessionId: string) => void | Promise; onSubmitPrompt: (prompt: string) => void | Promise; - onOpenTaskCollaboration?: (taskId: string) => Promise; + onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; @@ -91,12 +93,15 @@ type ScheduledConditionFeedProps = { onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; onTaskRefresh?: () => void; + onLoadTaskReports?: (taskId: string, offset: number) => Promise; + onReportViewed?: (taskId: string, generation: number, revision: number) => void; onRefresh?: () => void | Promise; }; type ScheduledConditionFeedFocusRequest = { conditionId: string; requestId: number; + actionId?: string | null; }; export function ScheduledConditionFeed({ @@ -127,6 +132,8 @@ export function ScheduledConditionFeed({ onTaskReconcile, onLoadTaskEvidenceSnapshot, onTaskRefresh, + onLoadTaskReports, + onReportViewed, onRefresh }: ScheduledConditionFeedProps) { const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false); @@ -200,6 +207,7 @@ export function ScheduledConditionFeed({ null; const focusConditionId = focusRequest?.conditionId; const focusRequestId = focusRequest?.requestId; + const [focusedAction, setFocusedAction] = useState<{ conditionId: string; actionId: string } | null>(null); const updateExpanded = useCallback( (nextExpanded: boolean) => { if (controlledExpanded === undefined) { @@ -261,12 +269,14 @@ export function ScheduledConditionFeed({ setTaskFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL); updateSelectedConditionId(targetCondition.id); + setFocusedAction(focusRequest?.actionId ? { conditionId: targetCondition.id, actionId: focusRequest.actionId } : null); updateExpanded(true); onFocusRequestHandled?.(focusRequestId); }, [ conditions, focusConditionId, focusRequestId, + focusRequest?.actionId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId @@ -325,14 +335,14 @@ export function ScheduledConditionFeed({ } } - function handleContinueConversation(condition: ScheduledConditionItem) { + function handleContinueConversation(condition: ScheduledConditionItem, discussion?: InvestigationDiscussion) { if (condition.kind === "work_order") { return; } if (condition.operationalTask) { onExpandAgent(); - void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id)) + void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id, discussion)) .catch((error) => showMapNotice({ id: "operational-task-collaboration", tone: "error", @@ -385,6 +395,7 @@ export function ScheduledConditionFeed({ return (
- {activeLoading && expanded ? ( + {activeLoading && expanded && !selectedCondition ? ( ) : expanded && selectedCondition ? ( handleContinueConversation(selectedCondition)} + onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)} onTaskDecision={onTaskDecision} onTaskExecute={onTaskExecute} onTaskDraftUpdate={onTaskDraftUpdate} @@ -522,6 +533,9 @@ export function ScheduledConditionFeed({ onTaskReconcile={onTaskReconcile} onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot} onTaskRefresh={onTaskRefresh} + onLoadTaskReports={onLoadTaskReports} + onReportViewed={onReportViewed} + focusActionId={focusedAction?.conditionId === selectedConditionId ? focusedAction.actionId : undefined} /> ) : null}
@@ -1098,14 +1112,15 @@ function ConditionTimelineRow({ > - + {condition.title} {condition.summary} - + + {condition.kind === "condition" && condition.hasNewReport ? 新报告 : null} diff --git a/src/features/workbench/components/task-conversation-review.tsx b/src/features/workbench/components/task-conversation-review.tsx index 28756ed..1ca50c0 100644 --- a/src/features/workbench/components/task-conversation-review.tsx +++ b/src/features/workbench/components/task-conversation-review.tsx @@ -1,6 +1,7 @@ import { ShieldCheck } from "lucide-react"; import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client"; import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel"; +import { investigationStatus } from "../utils/investigation-presentation"; type TaskConversationReviewProps = { task: OperationalTask; @@ -15,12 +16,21 @@ type TaskConversationReviewProps = { export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute, onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) { - const pendingAccess = task.access_requests.filter((request) => request.status === "pending"); + const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending"); const pendingActions = task.actions.filter((action) => action.approval_status === "pending" || action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status)); - if (pendingAccess.length === 0 && pendingActions.length === 0) return null; - return
-
+ const recentFacts = task.evidence.filter((item) => item.kind === "human_input") + .sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3); + return
+
+

当前第 {task.execution_generation} 轮 · {investigationStatus(task)}

+ {recentFacts.length > 0 ?
+ 最近已记录的人工事实 · {recentFacts.length} 条 +
    {recentFacts.map((item) =>
  • +

    {item.summary}

    {item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}

    + {item.observed_at ?

    现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}

    : null} +
  • )}
+
: null} {pendingAccess.map((request) => node.id === request.node_id)?.title} canDecide={task.permissions.can_decide_access} diff --git a/src/features/workbench/hooks/agent-session-message-state.test.ts b/src/features/workbench/hooks/agent-session-message-state.test.ts new file mode 100644 index 0000000..3f93256 --- /dev/null +++ b/src/features/workbench/hooks/agent-session-message-state.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state"; + +describe("task context in persisted answers", () => { + it("keeps the report reference of each answer when history is restored", () => { + const reference = { + task_id: "22222222-2222-4222-8222-222222222222", + execution_generation: 1, + report_revision: 3 + }; + const stored = [ + { + id: "answer-1", + role: "assistant", + parts: [ + { type: "data-task_context", data: reference }, + { type: "text", text: "该判断仅覆盖投诉时段的上游记录。" } + ] + } + ]; + const messages = toAgentChatMessages(toAgentUiMessages(stored), {}, {}); + expect(messages[0]?.taskContext).toEqual(reference); + expect(messages[0]?.content).toBe("该判断仅覆盖投诉时段的上游记录。"); + }); + it("keeps the answer readable when a report reference is invalid", () => { + const messages = toAgentChatMessages( + toAgentUiMessages([ + { + id: "answer-1", + role: "assistant", + parts: [ + { + type: "data-task_context", + data: { task_id: "unknown", execution_generation: -1, report_revision: 0 } + }, + { type: "text", text: "报告引用未记录。" } + ] + } + ]), + {}, + {} + ); + expect(messages[0]?.taskContext).toBeUndefined(); + expect(messages[0]?.content).toBe("报告引用未记录。"); + }); +}); diff --git a/src/features/workbench/hooks/agent-session-message-state.ts b/src/features/workbench/hooks/agent-session-message-state.ts index a9569e2..83db01c 100644 --- a/src/features/workbench/hooks/agent-session-message-state.ts +++ b/src/features/workbench/hooks/agent-session-message-state.ts @@ -2,6 +2,7 @@ import type { UIMessage } from "ai"; import type { Dispatch, SetStateAction } from "react"; import { applyPermissionResponse, + agentTaskContextReferenceSchema, applyQuestionResponse, toTodoUpdate, upsertPermission, @@ -16,6 +17,7 @@ import { } from "@/features/agent"; type AgentUiDataParts = { + task_context: Record; execution_plan: Record; progress: Record; todo_update: Record; @@ -123,7 +125,10 @@ export function toAgentChatMessages( if (!isAgentDataPart(part)) { continue; } - if (part.type === "data-progress") { + if (part.type === "data-task_context") { + const parsed = agentTaskContextReferenceSchema.safeParse(part.data); + if (parsed.success) next = { ...next, taskContext: parsed.data }; + } else if (part.type === "data-progress") { next = { ...next, progress: upsertProgress(next.progress, part.data) diff --git a/src/features/workbench/hooks/use-operational-tasks.ts b/src/features/workbench/hooks/use-operational-tasks.ts index b9ef1e3..9fabb0c 100644 --- a/src/features/workbench/hooks/use-operational-tasks.ts +++ b/src/features/workbench/hooks/use-operational-tasks.ts @@ -11,6 +11,7 @@ import { executeOperationalTaskAction, fetchOperationalTask, fetchOperationalTaskEvidenceSnapshot, + fetchOperationalTaskReports, fetchOperationalTaskNotifications, reconcileOperationalTaskAction, type OperationalTask, @@ -172,7 +173,10 @@ export function useOperationalTasks( }; }, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]); - const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]); + const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task), + hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated" + && item.report_reference != null && item.lifecycle_status === "active" && item.status === "unread"), + })), [tasks, notifications]); const createInvestigation = useCallback(async (input: CreateInvestigationInput) => { const task = await createOperationalTask({ @@ -297,6 +301,20 @@ export function useOperationalTasks( const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) => fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]); + const loadReports = useCallback((taskId: string, offset: number) => fetchOperationalTaskReports(taskId, offset, getAccessToken), [getAccessToken]); + const markingReportsRef = useRef(new Set()); + const markReportRead = useCallback((taskId: string, generation: number, revision: number) => { + const unread = notifications.filter((item) => item.task_id === taskId && item.kind === "report_updated" + && item.lifecycle_status === "active" && item.status === "unread" + && item.report_reference?.execution_generation === generation && item.report_reference.revision <= revision + && !markingReportsRef.current.has(item.notification_id)); + for (const item of unread) { + markingReportsRef.current.add(item.notification_id); + void acknowledgeNotification(item.notification_id, item.version) + .catch((error) => setError(error instanceof Error ? error.message : "新报告标记更新失败")) + .finally(() => markingReportsRef.current.delete(item.notification_id)); + } + }, [notifications, acknowledgeNotification]); return { error: error ?? sync.error, @@ -313,7 +331,9 @@ export function useOperationalTasks( confirmWorkOrderDispatch, decideAccess, acknowledgeNotification, - loadEvidenceSnapshot + loadEvidenceSnapshot, + loadReports, + markReportRead }; } @@ -361,6 +381,8 @@ function toConditionStatus(task: OperationalTask): ScheduledConditionStatus { function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel { const { priority, lifecycle_status: status } = task; + if (task.latest_report?.execution_generation === task.execution_generation + && task.latest_report?.report.outcome === "insufficient_data") return "attention"; if (status === "failed" || priority === "urgent") return "critical"; if (status === "waiting_human") return "attention"; return "normal"; diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index e8440c3..061288a 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -10,6 +10,8 @@ import { showMapNotice } from "@/features/map/core"; import { findOwnedWorkbenchProject } from "../api/project-client"; import { FrontendActionExecutor, + agentTaskDiscussionContextSchema, + type AgentTaskDiscussionContext, agentBootstrapKey, agentSessionsKey, createAgentApiClient, @@ -90,6 +92,7 @@ export function useWorkbenchAgent({ const [sessionId, setSessionId] = useState(null); const [sessionPurpose, setSessionPurpose] = useState("ordinary_chat"); const [sessionTaskId, setSessionTaskId] = useState(null); + const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null); const [registry, setRegistry] = useState(null); const [sessionTitle, setSessionTitle] = useState("新对话"); const [statusLabel, setStatusLabel] = useState("Agent 后端连接中"); @@ -231,7 +234,11 @@ export function useWorkbenchAgent({ api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`, headers: async (): Promise> => { const accessToken = await getAccessToken?.(); - return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + const projectId = await getProjectId(); + return { + ...(projectId ? { "X-Project-Id": projectId } : {}), + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) + }; }, prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) { return { @@ -248,7 +255,7 @@ export function useWorkbenchAgent({ }; } }), - [getAccessToken] + [getAccessToken, getProjectId] ); const chat = useChat({ @@ -575,7 +582,7 @@ export function useWorkbenchAgent({ [applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming] ); - const openTaskCollaboration = useCallback(async (taskId: string) => { + const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => { const normalizedTaskId = taskId.trim(); if (!normalizedTaskId) { throw new Error("工况任务 ID 不能为空。"); @@ -586,6 +593,7 @@ export function useWorkbenchAgent({ throw new Error("Agent 未返回任务协作会话。"); } await loadHistorySession(collaborationSessionId); + taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) }; return collaborationSessionId; }, [loadHistorySession]); @@ -709,7 +717,8 @@ export function useWorkbenchAgent({ body: { session_id: sessionIdRef.current ?? undefined, model: selectedModel || undefined, - approval_mode: approvalMode + approval_mode: approvalMode, + task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined } } ); diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index bbc8b5c..2fcb2c8 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -42,6 +42,7 @@ import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { TaskConversationReview } from "./components/task-conversation-review"; +import type { InvestigationDiscussion } from "./api/investigation-report"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { OperationalTimeline } from "./operational-timeline/operational-timeline"; import { @@ -144,6 +145,7 @@ export function MapWorkbenchPage({ const [conditionFocusRequest, setConditionFocusRequest] = useState<{ conditionId: string; requestId: number; + actionId?: string | null; } | null>(null); const [activeBaseLayerId, setActiveBaseLayerId] = useState( hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none" @@ -766,13 +768,9 @@ export function MapWorkbenchPage({ } function handleSelectAlert(alert: WorkbenchAlert) { - if (alert.id.startsWith("operational-task-notification:")) { - const notificationId = alert.id.slice("operational-task-notification:".length); - const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId); - if (notification?.status === "unread") { - void operationalTasks.acknowledgeNotification(notificationId, notification.version); - } - } + const notificationId = alert.id.startsWith("operational-task-notification:") + ? alert.id.slice("operational-task-notification:".length) : null; + const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId); const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id); if (conditionId) { const condition = scheduledConditions.find((item) => item.id === conditionId); @@ -788,8 +786,12 @@ export function MapWorkbenchPage({ openConditionWorkspaceForViewport(); setConditionFocusRequest((current) => ({ conditionId, - requestId: (current?.requestId ?? 0) + 1 + requestId: (current?.requestId ?? 0) + 1, + actionId: notification?.subject_type === "action" ? notification.subject_id : null })); + if (notification?.status === "unread") { + void operationalTasks.acknowledgeNotification(notification.notification_id, notification.version); + } return; } @@ -871,9 +873,10 @@ export function MapWorkbenchPage({ await agent.loadHistorySession(sessionId); } - async function handleOpenTaskCollaboration(taskId: string) { + async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) { mapController.clearScadaAnalysis(); - await agent.openTaskCollaboration(taskId); + await agent.openTaskCollaboration(taskId, discussion?.context); + if (discussion?.prompt) await agent.submitPrompt(discussion.prompt); } async function handleDeleteAgentHistorySession(sessionId: string) { @@ -1330,6 +1333,8 @@ export function MapWorkbenchPage({ onTaskReconcile={operationalTasks.reconcileAction} onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot} onTaskRefresh={operationalTasks.refresh} + onLoadTaskReports={operationalTasks.loadReports} + onReportViewed={operationalTasks.markReportRead} onRefresh={workbenchSync.refresh} /> @@ -1480,6 +1485,8 @@ export function MapWorkbenchPage({ onTaskReconcile={operationalTasks.reconcileAction} onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot} onTaskRefresh={operationalTasks.refresh} + onLoadTaskReports={operationalTasks.loadReports} + onReportViewed={operationalTasks.markReportRead} onRefresh={workbenchSync.refresh} /> )} diff --git a/src/features/workbench/types.ts b/src/features/workbench/types.ts index 796f8ef..8f8298c 100644 --- a/src/features/workbench/types.ts +++ b/src/features/workbench/types.ts @@ -134,6 +134,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase & { diff --git a/src/features/workbench/utils/investigation-diagram-style.test.ts b/src/features/workbench/utils/investigation-diagram-style.test.ts new file mode 100644 index 0000000..1be0fbc --- /dev/null +++ b/src/features/workbench/utils/investigation-diagram-style.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { styleInvestigationDiagram } from "./investigation-diagram-style"; + +describe("investigation diagram presentation", () => { + it("retains source relationships and only styles declared business references", () => { + const source = 'flowchart TD\nA["核查"] --> B;\nB -->|资料不足| C["记录缺口"]'; + const styled = styleInvestigationDiagram({ + title: "调查脉络", + mermaid: source, + node_refs: [ + { node_id: "B", label: "判断", evidence_refs: ["e1"], action_refs: [] }, + { node_id: "missing", label: "历史引用", evidence_refs: ["e2"], action_refs: [] } + ] + }); + expect(styled.startsWith(source)).toBe(true); + expect(styled).toContain("class B tjEvidence;"); + expect(styled).not.toContain("class missing "); + expect(styled).not.toMatch(/completed|running|succeeded/); + }); +}); diff --git a/src/features/workbench/utils/investigation-diagram-style.ts b/src/features/workbench/utils/investigation-diagram-style.ts new file mode 100644 index 0000000..1faefc2 --- /dev/null +++ b/src/features/workbench/utils/investigation-diagram-style.ts @@ -0,0 +1,109 @@ +import { mermaid, type MermaidConfig } from "@streamdown/mermaid"; +import type { InvestigationDiagram } from "../api/investigation-report"; + +export const INVESTIGATION_DIAGRAM_FONT = + '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif'; + +export const investigationDiagramConfig: MermaidConfig = { + securityLevel: "strict", + theme: "base", + htmlLabels: true, + fontFamily: INVESTIGATION_DIAGRAM_FONT, + themeVariables: { + fontFamily: INVESTIGATION_DIAGRAM_FONT, + fontSize: "15px", + background: "#FFFFFF", + primaryColor: "#FFFFFF", + primaryTextColor: "#26334D", + primaryBorderColor: "#A5B5CC", + secondaryColor: "#FFF6E8", + secondaryTextColor: "#78531B", + secondaryBorderColor: "#C3A575", + tertiaryColor: "#F6F8FC", + tertiaryBorderColor: "#CCD6E5", + tertiaryTextColor: "#475976", + lineColor: "#7689A6", + edgeLabelBackground: "#FFFFFF", + clusterBkg: "#F6F8FC", + clusterBorder: "#CCD6E5" + }, + flowchart: { + curve: "stepAfter", + nodeSpacing: 56, + rankSpacing: 64, + padding: 22, + diagramPadding: 24, + subGraphTitleMargin: { top: 12, bottom: 48 }, + wrappingWidth: 260 + }, + themeCSS: ` + .node rect { rx: 6px; ry: 6px; stroke-width: 1.4px; } + .node polygon { stroke-width: 1.6px; } + .nodeLabel { font-weight: 500; } + .nodeLabel p { text-wrap: balance; line-height: 1.6; } + .edgePath path, .flowchart-link { stroke-width: 1.35px; } + .edgeLabel { color: #536784; font-size: 12px; } + .edgeLabel p { padding: 3px 7px; border-radius: 4px; line-height: 1.5; } + .cluster rect { rx: 8px; ry: 8px; stroke-width: 1px; } + .cluster-label { font-weight: 600; font-size: 13px; } + .cluster-label .nodeLabel { background: #F6F8FC; padding: 0 4px; } + ` +}; + +/** Keep group captions beside crossing links, without moving nodes or changing edges. */ +export const investigationDiagramPlugins = { + mermaid: { + ...mermaid, + getMermaid(config?: MermaidConfig) { + const renderer = mermaid.getMermaid(config); + return { + initialize: (nextConfig: MermaidConfig) => renderer.initialize(nextConfig), + async render(id: string, source: string) { + const result = await renderer.render(id, source); + const svg = new DOMParser().parseFromString(result.svg, "image/svg+xml"); + svg.querySelectorAll(".cluster").forEach((cluster) => { + const rect = Array.from(cluster.children).find((child) => child.tagName === "rect"); + const caption = cluster.querySelector(".cluster-label"); + if (!rect || !caption) return; + const x = Number(rect.getAttribute("x")); + const y = Number(rect.getAttribute("y")); + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + caption.setAttribute( + "transform", + `translate(${x + 18}, ${y + (config?.flowchart?.subGraphTitleMargin?.top ?? 12)})` + ); + // Mermaid paints crossing edges after clusters; keep captions above those edges. + const clusters = cluster.parentElement; + if (clusters?.classList.contains("clusters")) clusters.parentElement?.append(caption); + }); + return { ...result, svg: new XMLSerializer().serializeToString(svg.documentElement) }; + } + }; + } + } +}; + +const roleStyles = [ + "classDef tjAgent fill:#EEF3FF,stroke:#7A93CB,color:#284574,stroke-width:1.4px;", + "classDef tjHuman fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.4px;", + "classDef tjDecision fill:#4263C7,stroke:#4263C7,color:#FFFFFF,stroke-width:1.5px;", + "classDef tjEvidence fill:#EDF7F4,stroke:#6B9E8B,color:#2D5E4E,stroke-width:1.6px;", + "classDef tjAction fill:#FFF6E8,stroke:#C4A168,color:#735018,stroke-width:1.6px;" +]; + +/** Style supplied relationships; never create nodes from a dangling display reference. */ +export function styleInvestigationDiagram(diagram: InvestigationDiagram) { + const assignments = diagram.node_refs.flatMap((node) => { + const escaped = node.node_id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const inSource = new RegExp(`\\b${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, "m").test( + diagram.mermaid + ); + if (!inSource) return []; + return node.action_refs.length + ? [`class ${node.node_id} tjAction;`] + : node.evidence_refs.length + ? [`class ${node.node_id} tjEvidence;`] + : []; + }); + return [diagram.mermaid, ...roleStyles, ...assignments].join("\n"); +} diff --git a/src/features/workbench/utils/investigation-presentation.ts b/src/features/workbench/utils/investigation-presentation.ts new file mode 100644 index 0000000..8e74840 --- /dev/null +++ b/src/features/workbench/utils/investigation-presentation.ts @@ -0,0 +1,104 @@ +import type { InvestigationDiagram } from "../api/investigation-report"; +import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client"; + +const diagramLabel = (value: string) => + value + .replace(/[\r\n]/g, " ") + .replace(/"/g, "#quot;") + .replace(//g, "#gt;") + .replace(/`/g, "#96;"); + +/** Preserve recorded relationships, without turning plan statuses into execution facts. */ +export function investigationDiagram( + task: OperationalTask, + snapshot: OperationalTaskReport | null +): InvestigationDiagram | null { + if (snapshot?.report.diagram) return snapshot.report.diagram; + const plan = task.latest_plan; + if ( + !plan || + (snapshot + ? snapshot.revision !== task.latest_report?.revision || + plan.execution_generation !== snapshot.execution_generation + : plan.execution_generation !== task.execution_generation) + ) + return null; + const ids = new Map(plan.plan.nodes.map((node, index) => [node.id, `n${index}`])); + const nodeDefinition = (node: (typeof plan.plan.nodes)[number]) => { + const label = `\`${diagramLabel(node.title)}\``; + return node.kind === "decision" || node.kind === "gate" + ? `${ids.get(node.id)}{"${label}"}:::tjDecision` + : `${ids.get(node.id)}["${label}"]:::${node.owner === "human" ? "tjHuman" : "tjAgent"}`; + }; + const agentNodes = plan.plan.nodes.filter((node) => node.owner === "agent"); + const humanNodes = plan.plan.nodes.filter((node) => node.owner === "human"); + const nodes = + agentNodes.length && humanNodes.length + ? [ + 'subgraph tj_agent_group["Agent · 资料核查"]', + ...agentNodes.map(nodeDefinition), + "end", + 'subgraph tj_human_group["人工 · 现场协作"]', + ...humanNodes.map(nodeDefinition), + "end" + ] + : plan.plan.nodes.map(nodeDefinition); + const edges = plan.plan.edges.map((edge) => + edge.condition + ? `${ids.get(edge.from)} -->|"${diagramLabel(edge.condition)}"| ${ids.get(edge.to)}` + : `${ids.get(edge.from)} --> ${ids.get(edge.to)}` + ); + return { + title: "调查脉络", + mermaid: ["flowchart TD", ...nodes, ...edges].join("\n"), + node_refs: plan.plan.nodes + .filter((node) => node.evidence_refs?.length || node.action_refs?.length) + .map((node) => ({ + node_id: ids.get(node.id)!, + label: node.title, + evidence_refs: node.evidence_refs ?? [], + action_refs: node.action_refs ?? [] + })) + }; +} + +export function investigationStatus(task: OperationalTask): string { + if (task.active_run?.status === "queued") return "等待调查"; + if (task.active_run?.status === "claimed") return "调查启动中"; + if (task.active_run?.status === "running") return "调查中"; + if (task.lifecycle_status === "failed") return "本轮执行失败"; + if (task.lifecycle_status === "cancelled") return "本轮已取消"; + if (task.actions.some((action) => action.approval_status === "pending")) return "待人工审核"; + if ( + task.actions.some( + (action) => + action.approval_status === "approved" && action.execution_status === "not_submitted" + ) + ) + return "待执行动作"; + if ( + task.actions.some( + (action) => + action.approval_status === "approved" && + ["submitted", "unknown"].includes(action.execution_status) + ) + ) + return "待核对回执"; + if ( + task.latest_report?.execution_generation === task.execution_generation && + task.latest_report.report.outcome === "insufficient_data" && + task.lifecycle_status === "completed" + ) + return "已结束 · 证据不足"; + if (task.lifecycle_status === "completed") return "本轮调查完成"; + return "调查中"; +} + +export function isPendingInvestigationAction(action: OperationalTask["actions"][number]) { + return ( + action.approval_status === "pending" || + (action.approval_status === "approved" && + ["not_submitted", "submitted", "unknown"].includes(action.execution_status)) + ); +} diff --git a/src/styles.css b/src/styles.css index 96553a9..495b5ac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1257,6 +1257,245 @@ button.agent-history-session-trigger:not(:disabled):active { column-gap: 0; } +/* Investigation reading surface: diagrams and evidence share the report's typography. */ +.investigation-report { + min-width: 0; + border: 1px solid var(--surface-divider); + border-radius: 0.75rem; + overflow: hidden; +} + +.investigation-report-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem; +} + +.investigation-report-header > button { flex-shrink: 0; } + +.investigation-report-section { + min-width: 0; + padding: 1rem; + border-top: 1px solid var(--surface-divider); +} + +.investigation-report-update { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--action-selection-soft); + color: var(--ink); + font-size: 0.75rem; +} + +.investigation-action-summary { + display: grid; + gap: 0.125rem; +} + +.investigation-action-summary-detail { + color: var(--ink-muted); + font-size: 0.6875rem; + font-weight: 400; + line-height: 1.25rem; +} + +.investigation-timeline { + position: relative; + display: grid; + gap: 0.75rem; + padding-block: 0.25rem 0.5rem; +} + +.investigation-timeline::before { + position: absolute; + inset-block: 0.65rem 0.9rem; + inset-inline-start: 0.1875rem; + width: 1px; + background: var(--surface-divider); + content: ""; +} + +.investigation-timeline-item { + position: relative; + padding-inline-start: 1rem; +} + +.investigation-timeline-item::before { + position: absolute; + inset-block-start: 0.45rem; + inset-inline-start: 0; + width: 0.4375rem; + height: 0.4375rem; + border: 1px solid var(--action-blue); + border-radius: 999px; + background: var(--surface-reading); + content: ""; +} + +.investigation-diagram { + min-width: 0; + overflow: hidden; + border: 1px solid var(--surface-divider); + border-radius: 0.75rem; + background: var(--palette-paper); +} + +.investigation-diagram [data-streamdown="mermaid"] { margin-block: 0; } +.investigation-diagram .font-mono { display: none; } +.investigation-diagram [data-streamdown="mermaid"] { background: var(--palette-paper); } +.investigation-diagram [data-streamdown="mermaid"] [role="img"] { width: 100%; } +.investigation-diagram .node { cursor: pointer; } +.investigation-diagram .node:hover { filter: brightness(0.97); } + +/* Match Button's compact icon-sm size, including controls portalled to body. */ +button[type="button"]:has(> .investigation-diagram-control-icon) { + --diagram-control-size: 2rem; + --diagram-control-icon-size: 1rem; + position: relative; + display: grid; + width: var(--diagram-control-size); + height: var(--diagram-control-size); + flex: 0 0 auto; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 0.5rem; + background: transparent; + color: var(--ink-muted); + cursor: pointer; +} + +button[type="button"]:has(> .investigation-diagram-control-icon)::after { + position: absolute; + inset: -4px; + content: ""; +} + +.investigation-diagram-control-icon { + width: var(--diagram-control-icon-size); + height: var(--diagram-control-icon-size); + flex-shrink: 0; + pointer-events: none; +} + +div:has(> button > [data-investigation-diagram-control="zoom-in"]), +.investigation-diagram [data-streamdown="mermaid-block-actions"] { + gap: 8px; + padding: 4px; + border: 1px solid var(--surface-border); + border-radius: var(--radius-control); + background: var(--surface-control); + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.investigation-diagram [data-streamdown="mermaid-block"] > div:first-child { display: none; } +.investigation-diagram [data-streamdown="mermaid-block"] > div:has(> [data-streamdown="mermaid-block-actions"]) { + height: auto; + margin-top: 0; +} + +button[type="button"]:has(> [data-investigation-diagram-control="close"]) { + position: absolute; + border-color: var(--surface-border); + background: var(--surface-control); +} + +@media (hover: hover) { + button[type="button"]:has(> .investigation-diagram-control-icon):hover:not(:disabled) { + border-color: var(--action-border-hover); + background: var(--action-soft); + color: var(--action-blue-hover); + } +} + +button[type="button"]:has(> .investigation-diagram-control-icon):active:not(:disabled) { + border-color: var(--action-border-pressed); + background: var(--action-soft-pressed); + color: var(--action-blue-pressed); + box-shadow: var(--action-shadow-secondary-pressed); + transform: scale(0.98); +} + +button[type="button"]:has(> .investigation-diagram-control-icon):focus-visible { + outline: 2px solid var(--action-focus-ring); + outline-offset: 2px; +} + +button[type="button"]:has(> .investigation-diagram-control-icon):disabled { + pointer-events: none; + opacity: 0.45; +} + +.investigation-node-link, +.investigation-history-row { + border: 1px solid var(--surface-divider); + border-radius: 0.5rem; + padding: 0.5rem 0.625rem; + color: var(--ink-muted); + text-align: left; + font-size: 0.75rem; + line-height: 1.25rem; + transition: background-color 120ms, color 120ms, transform 120ms; +} + +.investigation-node-link[aria-pressed="true"], +.investigation-history-row[aria-pressed="true"] { + border-color: var(--action-selection-border); + background: var(--action-selection-soft); + color: var(--ink); +} + +.investigation-history-row { + display: flex; + width: 100%; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem; + margin-block: 0.5rem; +} + +.investigation-text-action { + padding-block: 0.375rem; + color: var(--action-blue-hover); + font-size: 0.75rem; +} + +.investigation-node-link:focus-visible, +.investigation-history-row:focus-visible, +.investigation-text-action:focus-visible { + outline: 2px solid var(--action-focus-ring); + outline-offset: 2px; +} + +.investigation-node-link:active, +.investigation-history-row:active, +.investigation-text-action:active { transform: scale(0.98); } + +@media (hover: hover) { + .investigation-node-link:hover, + .investigation-history-row:hover { background: var(--action-selection-soft); color: var(--ink); } + .investigation-text-action:hover { text-decoration: underline; } +} + +@media (max-width: 400px) { + .investigation-report-header { flex-wrap: wrap; } + .investigation-report-section, + .investigation-report-header { padding: 0.75rem; } +} + +@media (prefers-reduced-motion: reduce) { + .investigation-node-link, + .investigation-history-row { transition: none; } +} + .scheduled-feed-shell-enter { animation: scheduled-feed-shell-show 170ms cubic-bezier(0.22, 1, 0.36, 1), diff --git a/tests/browser/dynamic-investigation-flow.e2e.ts b/tests/browser/dynamic-investigation-flow.e2e.ts index d23b166..2f828f1 100644 --- a/tests/browser/dynamic-investigation-flow.e2e.ts +++ b/tests/browser/dynamic-investigation-flow.e2e.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; -import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; +import { writeFile } from "node:fs/promises"; +import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; import { mockAgentApi } from "./support/mock-agent-api"; +import { expectThemeColor } from "./support/theme-colors"; const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; const taskId = "82000000-0000-4000-8000-000000000001"; @@ -8,7 +10,7 @@ const actionId = "83000000-0000-4000-8000-000000000001"; const accessId = "84000000-0000-4000-8000-000000000001"; const now = "2026-09-24T08:00:00Z"; -test("动态调查显示选定路线与嵌套并行,并完成授权和工单草稿确认", async ({ page }, testInfo) => { +test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常结束", async ({ page }, testInfo) => { test.setTimeout(90_000); await mockAgentApi(page); await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "text/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ @@ -34,7 +36,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 planNode("split", "同步核对现场与数据", "parallel_split", "completed"), planNode("field", "现场采样", "activity", "waiting"), planNode("data-split", "多源数据核对", "parallel_split", "completed"), - planNode("upstream", "上游监测", "activity", "completed"), + { ...planNode("upstream", "上游监测", "activity", "completed"), evidence_refs: ["monitor-1"] }, planNode("network", "管网运行", "activity", "completed"), planNode("data-join", "数据证据汇合", "parallel_join", "completed"), planNode("join", "现场与数据汇合", "parallel_join", "waiting"), @@ -64,7 +66,14 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 agent_session_id: "dynamic-flow-session", agent_lease_expires_at: null, current_plan_revision: 1, current_report_revision: null, created_at: now, updated_at: now, latest_plan: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session", published_at: now, published_by: "agent", plan }, - latest_report: null, evidence: [], + latest_report: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session", + published_at: now, published_by: "agent", report: { + title: "水质投诉调查报告", current_conclusion: "上游记录未见同期异常,需现场核查用户端。", conclusion_boundary: "仅覆盖已有上游监测记录。", + evidence_refs: ["monitor-1"], recommendations: ["现场核查"], pending_items: [], stage: "interim", outcome: "concluded", stop_reason: null, data_gaps: [], + diagram: { title: "调查脉络", mermaid: 'flowchart TD\nA["水质投诉"] --> B{"紧急风险?"}\nB -->|有| C["现场核查"]\nB -->|无| D["常规核查"]\nA --> E["供水路径"]\nsubgraph F["多源证据核对"]\nE --> G["上游监测"]\nE --> H["管网运行"]\nE --> I["相关投诉"]\nend\nG --> J["汇总判断"]\nH --> J\nI --> J\nC --> J\nD --> J\nJ --> K["动作草稿"]\nK --> L{"人工审核"}\nL -->|批准| M["执行与回执"]\nL -->|修改| K', + node_refs: [{ node_id: "G", label: "上游监测", evidence_refs: ["monitor-1"], action_refs: [] }, + { node_id: "K", label: "动作草稿", evidence_refs: [], action_refs: [actionId] }] } + } }, evidence: [{ evidence_id: "monitor-1", kind: "system_data", summary: "上游监测数据质量已核对", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }], access_requests: [{ access_request_id: accessId, task_id: taskId, node_id: "data-split", scopes: ["scada_devices", "scada_readings"], reason: "需要核对上游设备和读数", status: "pending", approved_scopes: [], version: 1, requested_by: "agent", requested_at: now, decided_by: null, decided_at: null, comment: null }], @@ -77,6 +86,8 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false, can_execute_commands: false, can_decide_access: true }, work_order_snapshot: null }; + const firstReport = task.latest_report!; + let notifications: OperationalTaskNotification[] = []; const summary = (item: OperationalTask): OperationalTaskSummary => { const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item; void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot; @@ -87,16 +98,24 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured", artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured", alert_availability: "not_configured", generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }, - tasks: [summary(task)], notifications: [], generated_at: now + tasks: [summary(task)], notifications, generated_at: now } })); await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" })); - await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })); + await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => { + if (route.request().method() === "POST") { + expect(route.request().postDataJSON().expected_version).toBe(1); + notifications = notifications.map((item) => ({ ...item, version: 2, status: "read", read_at: now, read_by: "reviewer" })); + return route.fulfill({ json: notifications[0] }); + } + return route.fulfill({ json: { items: notifications, limit: 100, offset: 0, next_offset: null } }); + }); await page.route("http://server.test/api/v1/operational-tasks**", async (route) => { const path = new URL(route.request().url()).pathname; const method = route.request().method(); if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } }); if (method === "GET" && path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } }); if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task }); + if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}/reports`)) return route.fulfill({ json: { items: [task.latest_report, firstReport].filter((item, index, items) => items.findIndex((other) => other?.revision === item?.revision) === index), next_offset: null } }); if (method === "POST" && path.endsWith(`/access-requests/${accessId}/decision`)) { const body = route.request().postDataJSON(); expect(body.approved_scopes).toEqual(["scada_devices"]); @@ -107,6 +126,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 if (method === "PATCH" && path.endsWith(`/actions/${actionId}/draft`)) { const body = route.request().postDataJSON(); expect(body.payload.title).toBe("现场采样修订稿"); + expect(body.payload.work_order_type).toBe("general"); const action = { ...task.actions[0], version: 2, payload: body.payload, edited_by: "reviewer", edited_at: now }; task = { ...task, actions: [action] }; return route.fulfill({ json: action }); @@ -129,35 +149,185 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 const panel = page.getByRole("region", { name: "工况任务", exact: true }); const expand = panel.getByRole("button", { name: "展开工况任务" }); if (await expand.isVisible()) await expand.click(); + const desktopColumns = panel.locator(".scheduled-feed-layout-expanded > div"); + const captureColumns = async () => { + const first = await desktopColumns.nth(0).boundingBox(); + const second = await desktopColumns.nth(1).boundingBox(); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + return [first!, second!].map((box) => ({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) })); + }; + await expect(panel).toHaveCSS("width", "880px"); + const scheduledColumns = await captureColumns(); + await testInfo.attach("scheduled-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("scheduled-tab-alignment.png") }), contentType: "image/png" }); + await panel.getByRole("button", { name: /预约任务/ }).click(); + await expect.poll(captureColumns).toEqual(scheduledColumns); + const appointmentColumns = await captureColumns(); await panel.getByRole("button", { name: /调查任务/ }).click(); await panel.getByText("水质投诉动态调查", { exact: true }).first().click(); - await expect(panel.getByText(/路线选择:/).first().locator("..")).toContainText("有紧急风险"); - await expect(panel.getByLabel("并行处理 2 路")).toHaveCount(2); - await expect(panel.locator('[data-execution-node="data-join"]')).toHaveCount(1); - await expect(panel.getByText("其他规划路线 1 项")).toBeVisible(); + await expect(panel.getByRole("heading", { name: "当前结果" })).toBeVisible(); + await expect.poll(captureColumns).toEqual(scheduledColumns); + await writeFile(testInfo.outputPath("tab-column-geometry.json"), JSON.stringify({ + viewport: page.viewportSize(), scheduled: scheduledColumns, appointment: appointmentColumns, + investigation: await captureColumns() + }, null, 2)); + const reportBounds = await desktopColumns.nth(0).boundingBox(); + const itemBounds = await desktopColumns.nth(1).boundingBox(); + expect(reportBounds).not.toBeNull(); + expect(itemBounds).not.toBeNull(); + expect(reportBounds!.x + reportBounds!.width).toBeLessThanOrEqual(itemBounds!.x); + expect(reportBounds!.width).toBeGreaterThan(itemBounds!.width); + await testInfo.attach("investigation-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-tab-alignment.png") }), contentType: "image/png" }); + const diagram = panel.getByLabel("调查脉络", { exact: true }); + await diagram.scrollIntoViewIfNeeded(); + await expect(diagram.locator("svg[id]")).toHaveCount(1); + await expect(diagram).toContainText("常规核查"); + const captureDiagramControls = async (surface: typeof diagram) => { + return surface.locator("button[title]").evaluateAll((buttons) => buttons.map((button) => { + const rect = button.getBoundingClientRect(); + const style = getComputedStyle(button); + const icon = button.querySelector("svg"); + const iconRect = icon?.getBoundingClientRect(); + return { title: button.getAttribute("title"), x: rect.x, y: rect.y, width: rect.width, height: rect.height, + radius: style.borderRadius, iconWidth: iconRect?.width, iconHeight: iconRect?.height }; + })); + }; + const inlineControls = await captureDiagramControls(diagram); + const assertControlGeometry = (controls: typeof inlineControls) => { + expect(controls).toHaveLength(4); + for (const control of controls) { + expect({ width: control.width, height: control.height, radius: control.radius, + iconWidth: control.iconWidth, iconHeight: control.iconHeight }).toEqual({ + width: 32, height: 32, radius: "8px", iconWidth: 16, iconHeight: 16 + }); + } + }; + assertControlGeometry(inlineControls); + const zoomIn = diagram.getByRole("button", { name: "Zoom in", exact: true }); + const zoomOut = diagram.getByRole("button", { name: "Zoom out", exact: true }); + const resetZoom = diagram.getByRole("button", { name: "Reset zoom and pan", exact: true }); + const diagramCanvas = diagram.locator('[role="application"]'); + await zoomIn.hover(); + await expectThemeColor(zoomIn, "background-color", "--action-soft"); + await expectThemeColor(zoomIn, "color", "--action-blue-hover"); + const zoomBox = (await zoomIn.boundingBox())!; + // The compact icon keeps a 40px hit area outside its 32px visible frame. + await page.mouse.move(zoomBox.x - 2, zoomBox.y + zoomBox.height / 2); + await page.mouse.down(); + await expectThemeColor(zoomIn, "background-color", "--action-soft-pressed"); + await page.mouse.up(); + await expect(diagramCanvas).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)"); + await resetZoom.click(); + await expect(diagramCanvas).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); + for (let index = 0; index < 8 && await zoomOut.isEnabled(); index++) await zoomOut.click(); + await expect(zoomOut).toBeDisabled(); + await expect(zoomOut).toHaveCSS("opacity", "0.45"); + await resetZoom.click(); + await expect(zoomOut).toBeEnabled(); + await zoomIn.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + await expect(zoomIn).toBeFocused(); + await expect(zoomIn).toHaveCSS("outline-width", "2px"); + await expectThemeColor(zoomIn, "outline-color", "--action-focus-ring"); + await zoomIn.locator("..").screenshot({ path: testInfo.outputPath("diagram-controls-keyboard-focus.png") }); + await testInfo.attach("styled-investigation-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("styled-investigation-diagram.png") }), contentType: "image/png" }); + await diagram.getByRole("button", { name: "全屏查看", exact: true }).click(); + const fullscreen = page.locator('body > div[role="button"]').filter({ has: page.getByRole("button", { name: "退出全屏", exact: true }) }); + await expect(fullscreen.locator("svg[id]")).toHaveCount(1); + const fullscreenControls = await captureDiagramControls(fullscreen); + assertControlGeometry(fullscreenControls); + await writeFile(testInfo.outputPath("diagram-control-geometry.json"), JSON.stringify({ viewport: page.viewportSize(), inline: inlineControls, fullscreen: fullscreenControls }, null, 2)); + await testInfo.attach("styled-investigation-fullscreen", { body: await page.screenshot({ path: testInfo.outputPath("styled-investigation-fullscreen.png") }), contentType: "image/png" }); + await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click(); + await expect(fullscreen).toHaveCount(0); + await diagram.getByRole("button", { name: "全屏查看", exact: true }).click(); + await expect(fullscreen).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(fullscreen).toHaveCount(0); + await diagram.locator(".node").filter({ hasText: "上游监测" }).click({ timeout: 5000 }); + await expect(panel.getByLabel("节点详情 上游监测")).toBeVisible(); + await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).click(); + await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).focus(); + await page.keyboard.press("Enter"); + await expect(panel.getByLabel("节点详情 上游监测")).toContainText("上游监测数据质量已核对"); await testInfo.attach("dynamic-investigation-desktop", { body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png" }); await panel.getByRole("button", { name: "Agent 协作" }).click(); const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" }); - const review = agentPanel.getByRole("region", { name: "当前任务审核" }); - await expect(review).toContainText("需要核对上游设备和读数"); + const review = agentPanel.getByRole("region", { name: "任务协作状态" }); + await expect(review).toContainText("现场采样需要派单"); + await expect(review.getByRole("heading", { name: "关联预约工单" })).toBeVisible(); + await expect(review).toContainText("调查任务只保留创建关联"); + await expect(review).toContainText("预约工单"); + await expect(review.getByText("数据访问申请", { exact: true })).toHaveCount(0); + await testInfo.attach("linked-appointment-review-desktop", { + body: await review.screenshot({ path: testInfo.outputPath("linked-appointment-review-desktop.png") }), contentType: "image/png" + }); await page.setViewportSize({ width: 375, height: 720 }); const mobileAgentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" }); if (!await mobileAgentSheet.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板" }).click(); - await expect(mobileAgentSheet.getByRole("region", { name: "当前任务审核" })).toBeVisible(); + await expect(mobileAgentSheet.getByRole("region", { name: "任务协作状态" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); await page.setViewportSize({ width: 1280, height: 720 }); await expect(review).toBeVisible(); - await review.getByLabel("SCADA 监测读数").uncheck(); - await review.getByRole("button", { name: "批准所选范围" }).click(); - await expect(panel.getByText("已授权:SCADA 设备")).toBeVisible(); + await expect(panel.getByLabel("数据访问审核")).toHaveCount(0); await review.getByRole("button", { name: "编辑草稿" }).click(); + await expect(review.getByRole("combobox", { name: "工单类型" })).toHaveCount(0); + await expect(review).toContainText("工单归属"); + await expect(review).toContainText("预约任务"); await review.getByRole("textbox", { name: "工单标题" }).fill("现场采样修订稿"); await review.getByRole("button", { name: "保存草稿" }).click(); await expect(panel.getByText("现场采样修订稿").first()).toBeVisible(); - await review.getByRole("button", { name: "确认并下发工单" }).click(); - await expect(panel.getByRole("region", { name: "执行回执" })).toContainText("WO-20260924-1"); + await review.getByRole("button", { name: "确认并创建预约工单" }).click(); + await expect(review.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0); + + task = { ...task, status: "completed", lifecycle_status: "completed", revision: 8, + access_requests: [], current_report_revision: 2, + evidence: [...task.evidence, { evidence_id: "sample-query", kind: "synthetic", summary: "已查询投诉时段,采样记录为空", source_label: "外围采样记录", source_ref: "sample-query", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }], + latest_plan: { ...task.latest_plan!, plan: { ...plan, nodes: plan.nodes.map((node) => ({ + ...node, status: node.status === "waiting" ? "skipped" as const : node.status, + detail: node.status === "waiting" ? "关键数据不足,本轮未进入" : node.detail + })) } }, + latest_report: { revision: 2, execution_generation: 1, source_session_id: "dynamic-flow-session", + published_at: now, published_by: "agent", report: { + title: "水质投诉调查报告", current_conclusion: "无法确定投诉原因", conclusion_boundary: "仅完成上游与管网数据核对", + evidence_refs: ["monitor-1", "sample-query"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data", + stop_reason: "缺少投诉时段的末梢采样结果", data_gaps: [{ gap_id: "sample-gap", node_id: "field", + missing_data: "末梢采样结果", scope: "投诉小区,2026-09-24 08:00 至 09:00", impact: "无法区分管网与用户端异常", + required_data: "补齐该时段末梢采样结果", evidence_refs: ["sample-query"], blocking: true }] + } } + }; + notifications = [{ notification_id: "87000000-0000-4000-8000-000000000001", task_id: taskId, kind: "report_updated", + title: "有新报告", description: "末梢采样缺失", lifecycle_status: "active", subject_type: "task", subject_id: taskId, + report_reference: { execution_generation: 1, revision: 2 }, resolved_at: null, resolution_reason: null, + status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null }]; + await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); + await expect(panel.getByRole("button", { name: "查看新报告" })).toBeVisible(); + await expect(panel.getByText("新报告", { exact: true })).toBeVisible(); + expect(notifications[0].status).toBe("unread"); + await expect(panel.getByLabel("任务报告", { exact: true }) + .getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible(); + await panel.getByRole("button", { name: "查看新报告" }).click(); + await expect.poll(() => notifications[0].status).toBe("read"); + await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible(); + await expect(panel.getByLabel("数据缺口")).toContainText("无法区分管网与用户端异常"); + await expect(panel.getByText("已结束 · 证据不足", { exact: true }).first()).toHaveAttribute("data-status-tone", "warning"); + await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0); + await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click(); + await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeVisible(); + await expect(panel).toHaveCSS("width", "880px"); + await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded(); + await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({ + path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" }); + await panel.getByText("任务时间线", { exact: true }).click(); + const timeline = panel.getByLabel("任务时间线记录"); + await expect(timeline).toContainText("采样工单已创建"); + await testInfo.attach("investigation-timeline-desktop", { body: await timeline.screenshot({ + path: testInfo.outputPath("investigation-timeline-desktop.png") }), contentType: "image/png" }); + await panel.getByText("任务时间线", { exact: true }).click(); for (const width of [375, 320]) { await page.setViewportSize({ width, height: 720 }); @@ -171,11 +341,54 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单 const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" }); if (await mobileExpand.isVisible()) await mobileExpand.click(); await mobilePanel.getByRole("button", { name: /调查任务/ }).click(); - await expect(mobilePanel.getByLabel("并行处理 2 路")).toHaveCount(2); + await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded(); + await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1); + const mobileDiagram = mobilePanel.getByLabel("调查脉络", { exact: true }); + assertControlGeometry(await captureDiagramControls(mobileDiagram)); + if (width === 320) { + await mobileDiagram.getByRole("button", { name: "全屏查看", exact: true }).click(); + await expect(fullscreen.locator("svg[id]")).toHaveCount(1); + assertControlGeometry(await captureDiagramControls(fullscreen)); + await testInfo.attach("diagram-fullscreen-320", { body: await page.screenshot({ path: testInfo.outputPath("diagram-fullscreen-320.png") }), contentType: "image/png" }); + await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click(); + } expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); - await mobilePanel.locator('[data-execution-node="split"]').scrollIntoViewIfNeeded(); + await mobilePanel.getByLabel("任务报告", { exact: true }).scrollIntoViewIfNeeded(); + await expect(mobilePanel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible(); await testInfo.attach(`dynamic-investigation-${width}`, { body: await sheet.screenshot({ path: testInfo.outputPath(`dynamic-investigation-${width}.png`) }), contentType: "image/png" }); } + await page.setViewportSize({ width: 1280, height: 720 }); + await panel.getByText("报告版本", { exact: true }).click(); + await expect(panel.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible(); + await panel.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click(); + await expect(panel.getByLabel("任务报告", { exact: true }) + .getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible(); + await page.route("**/api/v1/agent/chat/stream", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + expect(route.request().postDataJSON().task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" }); + await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" }, + body: [{ type: "start", messageId: "selected-report-answer" }, + { type: "data-task_context", data: { task_id: taskId, execution_generation: 1, report_revision: 1 } }, + { type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "这条依据仅覆盖上游监测范围。" }, + { type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }].map((part) => `data: ${JSON.stringify(part)}\n\n`).join("") }); + }); + await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click(); + await expect(agentPanel).toContainText("依据:第 1 轮 · 报告第 1 版"); + task = { ...task, revision: 9, execution_generation: 2, current_report_revision: 3, + latest_report: { ...firstReport, revision: 3, execution_generation: 2, report: { + ...firstReport.report, stage: "final", current_conclusion: "合成报告:仅用于检查图示失败时的阅读体验。", + diagram: { title: "调查脉络", mermaid: "flowchart TD\nA[未闭合", node_refs: [] } + } } }; + await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); + await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click(); + const reopen = panel.getByRole("button", { name: "展开工况任务" }); + if (await reopen.isVisible()) await reopen.click(); + const viewNew = panel.getByRole("button", { name: "查看新报告" }); + if (await viewNew.isVisible()) await viewNew.click(); + await panel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded(); + await expect(panel.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible(); + await expect(panel.getByLabel("任务报告", { exact: true }) + .getByText("合成报告:仅用于检查图示失败时的阅读体验。", { exact: true })).toBeAttached(); }); diff --git a/tests/browser/investigation-diagram-styles.e2e.ts b/tests/browser/investigation-diagram-styles.e2e.ts new file mode 100644 index 0000000..801ad10 --- /dev/null +++ b/tests/browser/investigation-diagram-styles.e2e.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; +import { mockAgentApi } from "./support/mock-agent-api"; + +test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => { + await page.route("**/api/v1/**", (route) => route.fulfill({ json: { items: [] } })); + await mockAgentApi(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.setViewportSize({ width: 1536, height: 1250 }); + await page.setContent(` +
+

调查脉络 · 三种视觉表达

+

使用截图中的节点与关系制作的样式设计示例

+
+

A · 工程流程

白底、分工分组、明确的节点和连线层次

+

B · 墨色报告

黑白、直角、虚实线区分分工,适合打印

+

C · 手绘白板

纸色、手绘线条,适合讲解设计思路

+
+
`); + const renderCandidate = async (candidate: string) => page.evaluate(async (selected) => { + const reactModule = await import("/node_modules/.vite/deps/react.js"); + const React = reactModule.default ?? reactModule; + const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js"); + const { createRoot } = clientModule.default ?? clientModule; + const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx"); + const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts"); + const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts"); + const titles = ["读取后端任务上下文与授权范围", "查询授权范围内的现有数据", "核实事件位置与生产资产映射", "现场核验爆管情况与影响范围", "停水隔离", "抢修作业", "复水与回访"]; + const edges = ["已读上下文后查询数据", "依据查询结果核实资产映射", "映射缺失需现场核验", "确认爆管后停水", "停水后抢修", "抢修后复水"]; + const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: { + nodes: titles.map((title, index) => ({ id: `node${index}`, title, owner: index < 3 ? "agent" : "human", kind: "activity", evidence_refs: [], action_refs: [] })), + edges: edges.map((condition, index) => ({ from: `node${index}`, to: `node${index + 1}`, condition })) + } } }, null); + await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, titles.join("")); + let source = styleInvestigationDiagram(diagram); + let config = investigationDiagramConfig; + if (selected === "ink") { + source += "\nclassDef tjAgent fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px;\nclassDef tjHuman fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px,stroke-dasharray:5 3;"; + config = { ...config, themeVariables: { ...config.themeVariables, clusterBkg: "#FFFFFF", clusterBorder: "#C5CBD5", lineColor: "#606A7B" }, + themeCSS: config.themeCSS + " .node rect,.cluster rect {rx:0;ry:0;} .nodeLabel{font-weight:400;} .cluster-label .nodeLabel{background:#FFFFFF;}" }; + } + if (selected === "sketch") { + source += "\nclassDef tjAgent fill:#EDF4E9,stroke:#788A6D,color:#3B5135,stroke-width:1.3px;\nclassDef tjHuman fill:#FFF4DC,stroke:#B39964,color:#6B572B,stroke-width:1.3px;"; + config = { ...config, look: "handDrawn", flowchart: { ...config.flowchart, curve: "basis", rankSpacing: 44 }, + themeVariables: { ...config.themeVariables, fontSize: "16px", clusterBkg: "#FFFCF4", clusterBorder: "#CAC3AF", lineColor: "#8E8979" }, + themeCSS: config.themeCSS + " .cluster-label .nodeLabel{background:#FFFCF4;}" }; + } + createRoot(document.getElementById(selected)).render(React.createElement(MessageResponse, { + plugins: investigationDiagramPlugins, + mermaid: { config }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } }, + children: "```mermaid\n" + source + "\n```" + })); + }, candidate); + for (const candidate of ["engineering", "ink", "sketch"]) { + await renderCandidate(candidate); + const surface = page.locator(`#${candidate}`); + await surface.scrollIntoViewIfNeeded(); + await expect(surface.locator("svg[id]")).toHaveCount(1); + await expect(surface).toContainText("读取后端任务上下文与授权范围"); + await expect(surface).toContainText("人工 · 现场协作"); + await writeFile(testInfo.outputPath(`${candidate}.svg`), await surface.locator("svg[id]").evaluate((svg) => svg.outerHTML)); + await surface.screenshot({ path: testInfo.outputPath(`${candidate}.png`) }); + } + await page.locator("#style-comparison").screenshot({ path: testInfo.outputPath("investigation-style-comparison.png") }); + await page.setViewportSize({ width: 1740, height: 1450 }); + await page.evaluate(async () => { + const reactModule = await import("/node_modules/.vite/deps/react.js"); + const React = reactModule.default ?? reactModule; + const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js"); + const { createRoot } = clientModule.default ?? clientModule; + const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx"); + const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts"); + const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts"); + const labels = ["工单接入与信息登记", "上游监测点数据质量核查", "上游演示指标阈值比对", "相邻片区同类投诉排查", "投诉分布范围确认", "阶段性判断", "二次供水水箱清洗记录核验", "现场采样", "水箱清洗处置", "复测", "居民回访"]; + const links = [[0,1,"并行核查"],[0,2,"并行核查"],[0,3,"并行核查"],[0,4,"并行核查"], + [1,5,"数据可用性结论"],[2,5,"上游水质结论"],[3,5,"范围排除依据"],[4,5,"投诉分布依据"], + [5,7,"条件分支:需现场核验"],[5,6,"条件分支:疑似二次供水问题 → 核验清洗记录"], + [6,8,"条件分支:清洗记录缺失或异常 → 清洗处置"],[7,9,"条件分支:采样支持处置后复测"], + [8,9,"条件分支:处置后复测"],[9,10,"条件分支:复测结果反馈回访"]]; + const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: { + nodes: labels.map((title, index) => ({ id: `parallel${index}`, title, owner: index < 6 ? "agent" : "human", kind: "activity", + evidence_refs: index < 7 ? [`design-example-${index}`] : [], action_refs: [] })), + edges: links.map(([from,to,condition]) => ({ from: `parallel${from}`, to: `parallel${to}`, condition })) + } } }, null); + await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, labels.join("")); + document.getElementById("style-comparison").remove(); + const container = document.createElement("main"); container.id = "parallel-comparison"; + container.style.cssText = "padding:32px;background:#FFFFFF;font-family:'Noto Sans SC Variable',sans-serif"; + document.body.append(container); + createRoot(container).render(React.createElement(MessageResponse, { + plugins: investigationDiagramPlugins, + mermaid: { config: investigationDiagramConfig }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } }, + children: "```mermaid\n" + styleInvestigationDiagram(diagram) + "\n```" + })); + const style = document.createElement("style"); + style.textContent = '#parallel-comparison [data-streamdown="mermaid"],#parallel-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:#FFFFFF} #parallel-comparison [data-streamdown="mermaid"] [role="img"]{width:100%} #parallel-comparison .font-mono{display:none}'; + document.head.append(style); + }); + const parallel = page.locator("#parallel-comparison"); + await parallel.scrollIntoViewIfNeeded(); + await expect(parallel.locator("svg[id]")).toHaveCount(1); + const titleGap = await parallel.evaluate((element) => { + const title = [...element.querySelectorAll(".cluster-label")].find((item) => item.textContent?.includes("Agent")); + const firstNode = [...element.querySelectorAll(".node")].find((item) => item.textContent?.includes("工单接入")); + return firstNode!.getBoundingClientRect().top - title!.getBoundingClientRect().bottom; + }); + const captionOverlaps = await parallel.evaluate((element) => { + const labels = [...element.querySelectorAll(".edgeLabel")].map((label) => label.getBoundingClientRect()); + return [...element.querySelectorAll(".cluster-label")].flatMap((caption) => { + const box = caption.getBoundingClientRect(); + return labels.some((label) => box.left < label.right && box.right > label.left && box.top < label.bottom && box.bottom > label.top) + ? [caption.textContent] + : []; + }); + }); + await writeFile(testInfo.outputPath("parallel-diagram.svg"), await parallel.locator("svg[id]").evaluate((svg) => svg.outerHTML)); + await parallel.screenshot({ path: testInfo.outputPath("parallel-diagram.png") }); + await writeFile(testInfo.outputPath("parallel-diagram-spacing.json"), JSON.stringify({ viewport: page.viewportSize(), titleGap }, null, 2)); + expect(titleGap).toBeGreaterThanOrEqual(18); + expect(captionOverlaps).toEqual([]); +});