feat(workbench): redesign investigation reports and diagrams
This commit is contained in:
@@ -1283,6 +1283,10 @@ function BackendMessageList({
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{message.role === "assistant" && message.taskContext ? <p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
|
||||
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
|
||||
</p> : null}
|
||||
{message.role === "assistant" && !qaOnly ? (
|
||||
<AgentMessageProgress
|
||||
progress={message.progress}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
|
||||
export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type AgentTaskDiscussionContext, type AgentTaskContextReference } from "./task-context";
|
||||
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
|
||||
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
|
||||
export { AgentCommandPanel } from "./components/agent-command-panel";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const agentTaskDiscussionContextSchema = z
|
||||
.object({
|
||||
report_revision: z.number().int().positive().optional(),
|
||||
evidence_id: z.string().min(1).max(128).optional(),
|
||||
gap_id: z.string().min(1).max(128).optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AgentTaskDiscussionContext = z.infer<typeof agentTaskDiscussionContextSchema>;
|
||||
|
||||
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<typeof agentTaskContextReferenceSchema>;
|
||||
@@ -6,6 +6,7 @@ export type AgentChatMessage = {
|
||||
permissions?: AgentPermissionRequest[];
|
||||
questions?: AgentQuestionRequest[];
|
||||
todos?: AgentTodoUpdate;
|
||||
taskContext?: import("./task-context").AgentTaskContextReference;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderMessageState = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof investigationReportSchema>;
|
||||
export type InvestigationDiagram = z.infer<typeof investigationDiagramSchema>;
|
||||
@@ -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<typeof actionSchema>;
|
||||
export type OperationalTaskAccessRequest = z.infer<typeof accessRequestSchema>;
|
||||
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
|
||||
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
|
||||
export type OperationalTaskReport = z.infer<typeof reportRevisionSchema>;
|
||||
export type OperationalTaskReportPage = z.infer<typeof reportPageSchema>;
|
||||
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
|
||||
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
|
||||
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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(<AgentExecutionPlanPanel {...props} task={{ ...task, execution_generation: 2, status: "running", lifecycle_status: "open" }} />);
|
||||
expect(screen.queryByText("本轮已结束 · 无法形成结论")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<typeof AgentExecutionPlanPanel>> = {}) {
|
||||
const props = {
|
||||
task,
|
||||
@@ -108,6 +125,8 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
||||
...overrides
|
||||
};
|
||||
const { rerender } = render(<AgentExecutionPlanPanel {...props} />);
|
||||
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(<ExecutionPlanSteps plan={plan} context={taskBase} onLoadEvidenceSnapshot={vi.fn()} />);
|
||||
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(<ExecutionPlanSteps plan={plan} context={{ evidence: [], access_requests: [], actions: [] }} onLoadEvidenceSnapshot={vi.fn()} />);
|
||||
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(<ExecutionPlanSteps plan={plan} />);
|
||||
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(<AgentExecutionPlanPanel {...props} task={{ ...initialTask, actions: [{ ...initialTask.actions[0], version: 4, payload: { ...initialTask.actions[0].payload, title: "人工修订草稿" } }] }} />);
|
||||
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(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, status: "failed" }}
|
||||
task={{ ...previousReport, status: "failed", lifecycle_status: "failed" }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
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(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, status: "cancelled" }}
|
||||
task={{ ...previousReport, status: "cancelled", lifecycle_status: "cancelled" }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible();
|
||||
expect(screen.getByText("本轮已取消", { exact: true })).toBeVisible();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, execution_generation: null } }}
|
||||
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, revision: 2, execution_generation: null } }}
|
||||
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
|
||||
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
|
||||
/>);
|
||||
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<OperationalTask["latest_plan"]> = {
|
||||
revision: 1,
|
||||
execution_generation: 1,
|
||||
@@ -245,26 +327,27 @@ describe("operational task approval and evidence", () => {
|
||||
onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn()
|
||||
};
|
||||
const { rerender } = render(<AgentExecutionPlanPanel task={currentPlanTask} {...props} />);
|
||||
expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "调查脉络" })).toBeVisible();
|
||||
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, status: "running", execution_generation: 2 }}
|
||||
{...props}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible();
|
||||
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, status: "failed", execution_generation: 2 }}
|
||||
{...props}
|
||||
/>);
|
||||
expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible();
|
||||
expect(screen.getByText("上一轮报告", { exact: true })).toBeVisible();
|
||||
|
||||
rerender(<AgentExecutionPlanPanel
|
||||
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
|
||||
{...props}
|
||||
/>);
|
||||
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("上游余氯处于正常范围")
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<OperationalTask, "evidence" | "access_requests" | "actions">;
|
||||
|
||||
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<unknown>;
|
||||
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -64,6 +71,9 @@ type AgentExecutionPlanPanelProps = {
|
||||
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onRefresh: () => void;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
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 <InvestigationTaskReport key={task.task_id} task={task}
|
||||
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId}
|
||||
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
|
||||
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
|
||||
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
|
||||
/>;
|
||||
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 (
|
||||
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
|
||||
<InvestigationReportView task={task} reportLabel={reportLabel} />
|
||||
<section className="surface-reading overflow-hidden rounded-xl border border-[var(--surface-divider)]/70">
|
||||
<div className="space-y-2 border-b border-[var(--surface-divider)]/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -130,8 +160,8 @@ export function AgentExecutionPlanPanel({
|
||||
<Route size={13} aria-hidden="true" />
|
||||
{task.task_type === "external_work_order" ? "外部工单进度" : "调查处理流程"}
|
||||
</h3>
|
||||
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? "success" : "info"}>
|
||||
{taskStatusLabel(task.status)}
|
||||
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? report?.outcome === "insufficient_data" && reportState === "current" ? "warning" : "success" : "info"}>
|
||||
{task.lifecycle_status === "completed" && reportState === "current" && report?.outcome === "insufficient_data" ? "已结束 · 证据不足" : taskStatusLabel(task.status)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-600" role="status">
|
||||
@@ -144,7 +174,15 @@ export function AgentExecutionPlanPanel({
|
||||
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
|
||||
<ExternalProgress evidence={externalEvidence} />
|
||||
) : plan ? (
|
||||
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
|
||||
<>
|
||||
<div className="border-b border-[var(--surface-divider)]/70 px-3 py-2.5 text-xs leading-5 text-slate-700">
|
||||
<p className="font-semibold text-slate-900">{plan.title} <span className="font-normal text-[var(--ink-muted)]">· 第 {task.latest_plan?.revision} 版</span></p>
|
||||
<p className="mt-0.5 text-slate-600">规划依据:{plan.reason}</p>
|
||||
{plan.schema === "execution-plan@2" ? <p className="mt-0.5 text-slate-600">{selectedRoutes.length ? `实际路线:${selectedRoutes.join(";")}` : plan.nodes.some((node) => node.kind === "decision") ? "实际路线:待判断" : "实际路线:按计划执行"}</p> : null}
|
||||
{waitingNodes.length > 0 ? <p className="mt-0.5 text-[var(--status-warning-foreground)]">等待:{waitingNodes.join("、")}</p> : null}
|
||||
</div>
|
||||
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
</>
|
||||
) : (
|
||||
<p className="p-3 text-xs leading-5 text-[var(--ink-muted)]">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
|
||||
)}
|
||||
@@ -156,38 +194,6 @@ export function AgentExecutionPlanPanel({
|
||||
canDecide={task.permissions.can_decide_access} onDecision={onAccessDecision} onRefresh={onRefresh} />)}
|
||||
</section> : null}
|
||||
|
||||
<section aria-label="任务报告" className="surface-reading space-y-3 rounded-xl border border-slate-200/70 p-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-500">结论与证据独立呈现,不展示模型推理或节点执行日志。</p>
|
||||
</div>
|
||||
{report ? (
|
||||
<>
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2.5",
|
||||
reportState === "current" ? "bg-blue-50/70" : "bg-amber-50/80"
|
||||
)}>
|
||||
<p className={cn(
|
||||
"text-[11px] font-semibold",
|
||||
reportState === "current" ? "text-blue-700" : "text-amber-800"
|
||||
)}>
|
||||
{reportLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-6 text-slate-800">{report.current_conclusion}</p>
|
||||
</div>
|
||||
{report.recommendations.length > 0 ? (
|
||||
<ReportList title="处置建议" items={report.recommendations} tone="blue" />
|
||||
) : null}
|
||||
{report.pending_items.length > 0 ? (
|
||||
<ReportList title="待确认事项" items={report.pending_items} tone="amber" />
|
||||
) : null}
|
||||
<p className="border-t border-slate-200/70 pt-2 text-[11px] leading-5 text-slate-500">结论边界:{report.conclusion_boundary}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<EvidenceSection evidence={task.evidence} referencedIds={report?.evidence_refs ?? []} onLoadSnapshot={onLoadEvidenceSnapshot} />
|
||||
|
||||
{unlinkedActions.length > 0 ? (
|
||||
@@ -209,7 +215,7 @@ export function AgentExecutionPlanPanel({
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onContinue}>
|
||||
<Button variant="outline" size="sm" onClick={() => onContinue()}>
|
||||
<MessageSquare size={14} aria-hidden="true" />Agent 协作
|
||||
</Button>
|
||||
</div>
|
||||
@@ -217,60 +223,58 @@ export function AgentExecutionPlanPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-slate-600">{title}</p>
|
||||
<ul className="mt-1.5 grid gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="grid grid-cols-[6px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", tone === "blue" ? "bg-blue-500" : "bg-amber-400")} aria-hidden="true" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record<string, ReactNode> }) {
|
||||
export function ExecutionPlanSteps({ plan, nodeActions = {}, context, onLoadEvidenceSnapshot }: {
|
||||
plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan;
|
||||
nodeActions?: Record<string, ReactNode>;
|
||||
context?: ExecutionFlowContext;
|
||||
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
}) {
|
||||
return <div aria-label="调查执行步骤" className="px-3 py-3">{
|
||||
plan.nodes.some((node) => node.kind)
|
||||
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} />
|
||||
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
|
||||
}</div>;
|
||||
}
|
||||
|
||||
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
|
||||
type SemanticFlowProps = {
|
||||
plan: ExecutionPlanView;
|
||||
nodeActions: Record<string, ReactNode>;
|
||||
context?: ExecutionFlowContext;
|
||||
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
};
|
||||
|
||||
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 <div className="space-y-2">
|
||||
<div className="space-y-2 border-l-2 border-[var(--surface-divider)] pl-3">
|
||||
{roots.filter((node) => visible.has(node.id)).map((node) => <SemanticPath key={node.id} nodeId={node.id} stopId={null} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={new Set()} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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 <div className="space-y-3">
|
||||
<div className="space-y-2 border-l border-slate-200 pl-3">
|
||||
{roots.filter((node) => visible.has(node.id)).map((node) => <SemanticPath key={node.id} nodeId={node.id} stopId={null} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={new Set()} />)}
|
||||
</div>
|
||||
{alternatives.length > 0 ? <Disclosure label={`其他规划路线 ${alternatives.length} 项`} className="surface-control rounded-lg border border-slate-200/70 px-2.5">
|
||||
<ol className="grid gap-2 border-t border-slate-200/70 py-2">{alternatives.map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact /></li>)}</ol>
|
||||
</Disclosure> : null}
|
||||
</div>;
|
||||
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<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
|
||||
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>; visible: Set<string>;
|
||||
context?: ExecutionFlowContext; onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
}) {
|
||||
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<string>();
|
||||
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 <div className="space-y-2">
|
||||
<ExecutionNodeCard node={node} action={nodeActions[node.id]} />
|
||||
<ExecutionNodeCard node={node} action={nodeActions[node.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} />
|
||||
{node.kind === "decision" ? <div className="rounded-lg bg-[var(--action-soft)]/70 px-3 py-2 text-xs leading-5 text-slate-700">
|
||||
<span className="font-semibold">路线选择:</span>{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"}
|
||||
<div className="mt-1 text-[var(--ink-muted)]">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
|
||||
<span className="font-semibold">路线选择:</span>{selectedRoute?.condition ?? selectedRoute?.label ?? "等待判断"}
|
||||
{alternativeGroups.length > 0 ? <Disclosure label={`${selectedRoute ? "其他规划路线" : "待选路线"} ${alternativeGroups.length} 项`} className="mt-1 border-t border-[var(--surface-border)]">
|
||||
<div className="grid gap-2 border-t border-[var(--surface-border)] py-2">{alternativeGroups.map(({ edge, nodes }) => <section key={edge.id ?? edge.to} className="rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2" aria-label={`规划路线 ${edge.condition ?? edge.label ?? edge.to}`}>
|
||||
<p className="font-semibold text-slate-700">{edge.condition ?? edge.label ?? "未命名路线"} <span className="font-normal text-[var(--ink-muted)]">· {selectedRoute ? "未选择" : "待判断"}</span></p>
|
||||
{nodes.length > 0 ? <ol className="mt-1.5 grid gap-1.5">{nodes.map((item) => <li key={item.id}><ExecutionNodeCard node={item} action={nodeActions[item.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} compact /></li>)}</ol> : <p className="mt-1 text-[var(--ink-muted)]">此路线在后续节点汇合。</p>}
|
||||
</section>)}</div>
|
||||
</Disclosure> : null}
|
||||
</div> : null}
|
||||
{predecessors.length > 0 ? <p className="rounded-lg bg-[var(--surface-reading)] px-3 py-2 text-[11px] leading-5 text-slate-600">汇合前置:{predecessors.map((item) => `${item.title}(${executionNodePresentation(item).label})`).join("、")}</p> : null}
|
||||
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-[var(--surface-border)] bg-[var(--action-soft)]/40 p-2">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-[var(--action-blue-pressed)]"><GitFork size={13} aria-hidden="true" />并行处理 · {outgoing.length} 路</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 rounded-lg border border-[var(--surface-divider)]/70 bg-[var(--surface-reading)] p-2">
|
||||
<div className="grid gap-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 border-l-2 border-[var(--surface-border)] pl-2">
|
||||
<p className="mb-1.5 text-[11px] font-semibold text-slate-600">分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}</p>
|
||||
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} />
|
||||
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
</div>)}</div>
|
||||
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
|
||||
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
|
||||
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
|
||||
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -315,19 +335,36 @@ function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; n
|
||||
})}</ol></div>;
|
||||
}
|
||||
|
||||
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<OperationalTaskDataSnapshot>;
|
||||
nodeById?: Map<string, ExecutionNode>;
|
||||
}) {
|
||||
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 (
|
||||
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div>
|
||||
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
|
||||
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "审核关口" }[node.kind]}</p> : null}
|
||||
{node.kind ? <p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">负责:{node.owner === "human" ? "人工" : "Agent"}</p> : null}
|
||||
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
|
||||
{hasDetails ? <Disclosure label={`查看关联详情${evidence.length ? ` · ${evidence.length} 条证据` : ""}`} className="mt-1 border-t border-[var(--surface-divider)]/70">
|
||||
<div className="grid gap-2 border-t border-[var(--surface-divider)]/70 py-2 text-[11px] leading-5 text-slate-700">
|
||||
{node.revisits_node_id ? <p>再次核验:{nodeById?.get(node.revisits_node_id)?.title ?? `节点 ${node.revisits_node_id}`}</p> : null}
|
||||
{evidence.length > 0 ? <div><p className="font-semibold">节点依据</p><div className="mt-1 grid gap-1">{evidence.map((item) => typeof item === "string" ? <p key={item} className="text-[var(--status-warning-foreground)]">证据 {item} 不可用</p> : onLoadEvidenceSnapshot ? <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} /> : <p key={item.evidence_id}>{item.summary} · {item.source_label}</p>)}</div></div> : null}
|
||||
{node.gate_ref ? <p>审核关联:{gate ? "scopes" in gate ? `数据访问${gate.status === "approved" ? "已批准" : gate.status === "rejected" ? "已拒绝" : "待审核"},${gate.reason}` : `动作${approvalLabel(gate.approval_status)},${gate.reason}` : "关联记录不可用"}</p> : null}
|
||||
{relatedActions.length > 0 ? <div><p className="font-semibold">关联动作</p>{relatedActions.map((item) => <p key={typeof item === "string" ? item : item.action_id}>{typeof item === "string" ? `动作 ${item} 不可用` : `${item.reason} · ${approvalLabel(item.approval_status)} · ${executionLabel(item.execution_status)}`}</p>)}</div> : null}
|
||||
</div>
|
||||
</Disclosure> : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(action.payload);
|
||||
const command = action.kind === "issue_command";
|
||||
const linkedAppointment = !command && task.task_type === "event_investigation";
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(() => 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
|
||||
<div className="flex items-start gap-2.5">
|
||||
<StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : linkedAppointment ? "关联预约工单" : "预约工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-[var(--ink-muted)]">原因:</span>{action.reason}</p>
|
||||
<ActionPayloadSummary payload={action.payload} />
|
||||
{linkedAppointment ? <p className="mt-2 rounded-lg bg-sky-50/80 px-2.5 py-2 text-xs leading-5 text-slate-700">调查任务只保留创建关联。创建后请到“预约任务”查看派发进度、现场反馈和复令。</p> : null}
|
||||
<ActionPayloadSummary payload={displayedPayload} />
|
||||
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2.5">
|
||||
<Input aria-label="工单标题" value={String(draft.title ?? "")} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
|
||||
<Input aria-label="工单说明" value={String(draft.description ?? "")} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-2"><select aria-label="工单类型" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.work_order_type ?? "general")} onChange={(event) => setDraft({ ...draft, work_order_type: event.target.value })}><option value="general">一般工单</option><option value="water_quality_complaint">水质投诉</option><option value="burst_report">爆管上报</option></select><select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select></div>
|
||||
<label className="grid gap-1 text-[11px] font-semibold text-[var(--ink-muted)]">工单归属<span className="form-field flex min-h-10 items-center rounded-lg border border-[var(--surface-divider)] bg-slate-50 px-2 text-xs font-normal text-slate-700">预约任务</span></label>
|
||||
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
|
||||
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
|
||||
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
|
||||
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
|
||||
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(action.payload); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
|
||||
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
|
||||
</div> : null}
|
||||
{action.evidence_refs.length > 0 ? (
|
||||
<div className="mt-2 rounded-lg bg-[var(--action-soft)]/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
|
||||
@@ -506,7 +547,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
|
||||
{action.receipt ? <ActionReceipt receipt={action.receipt} title={linkedAppointment ? "预约工单创建回执" : undefined} /> : null}
|
||||
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-700">{action.execution_detail}</p> : null}
|
||||
<Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-[var(--surface-divider)]/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-[var(--surface-divider)]/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure>
|
||||
<p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
|
||||
@@ -515,8 +556,8 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
|
||||
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(action.payload); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并下发工单" : "批准创建工单"}</Button></> : null}
|
||||
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建工单"}</Button> : null}
|
||||
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
|
||||
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
|
||||
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,9 +566,9 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
);
|
||||
}
|
||||
|
||||
function ActionReceipt({ receipt }: { receipt: NonNullable<OperationalTaskAction["receipt"]> }) {
|
||||
return <section aria-label="执行回执" className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
|
||||
<p className="text-xs font-semibold text-emerald-800">执行回执</p>
|
||||
function ActionReceipt({ receipt, title = "执行回执" }: { receipt: NonNullable<OperationalTaskAction["receipt"]>; title?: string }) {
|
||||
return <section aria-label={title} className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
|
||||
<p className="text-xs font-semibold text-emerald-800">{title}</p>
|
||||
<dl className="mt-1 grid gap-1 text-[11px] leading-5">
|
||||
<EvidenceMeta label="回执编号" value={receipt.receipt_id} mono />
|
||||
<EvidenceMeta label="外部状态" value={externalStatusLabel(receipt.external_status)} />
|
||||
@@ -548,6 +589,10 @@ function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> })
|
||||
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-[var(--surface-divider)]/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
|
||||
}
|
||||
|
||||
function workOrderDraft(payload: Record<string, unknown>) {
|
||||
return { ...payload, work_order_type: "general" };
|
||||
}
|
||||
|
||||
function payloadLabel(key: string) {
|
||||
const labels: Record<string, string> = {
|
||||
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] };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SVGSVGElement> & { size?: number }) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
aria-hidden
|
||||
className={cn("investigation-diagram-control-icon", className)}
|
||||
data-investigation-diagram-control={control}
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<p role="status" className="p-4 text-sm leading-6 text-[var(--ink-muted)]">
|
||||
图示暂不可用,仍可查看结论、证据和待审核动作。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<section aria-label="调查脉络" className="investigation-report-section">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
|
||||
<GitBranch size={15} aria-hidden />
|
||||
{diagram.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
|
||||
</p>
|
||||
<div
|
||||
className="investigation-diagram mt-3"
|
||||
onPointerDownCapture={(event) => {
|
||||
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 ? (
|
||||
<MessageResponse
|
||||
icons={diagramControlIcons}
|
||||
plugins={investigationDiagramPlugins}
|
||||
translations={{
|
||||
viewFullscreen: "全屏查看",
|
||||
exitFullscreen: "退出全屏"
|
||||
}}
|
||||
mermaid={{
|
||||
errorComponent: DiagramUnavailable,
|
||||
config: investigationDiagramConfig
|
||||
}}
|
||||
controls={{
|
||||
mermaid: { fullscreen: true, download: false, copy: false, panZoom: true }
|
||||
}}
|
||||
>{`\`\`\`mermaid\n${styleInvestigationDiagram(diagram)}\n\`\`\``}</MessageResponse>
|
||||
) : (
|
||||
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
|
||||
)}
|
||||
</div>
|
||||
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
aria-label="图示颜色说明"
|
||||
>
|
||||
{diagram.node_refs.some(
|
||||
(node) => node.evidence_refs.length && !node.action_refs.length
|
||||
) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
|
||||
/>
|
||||
引用证据
|
||||
</span>
|
||||
) : null}
|
||||
{diagram.node_refs.some((node) => node.action_refs.length) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
|
||||
/>
|
||||
关联业务动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{diagram.node_refs.length > 0 ? (
|
||||
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
|
||||
{diagram.node_refs.map((node) => (
|
||||
<button
|
||||
key={node.node_id}
|
||||
type="button"
|
||||
aria-pressed={selectedNodeId === node.node_id}
|
||||
className="investigation-node-link"
|
||||
onClick={() =>
|
||||
setSelectedNodeId(selectedNodeId === node.node_id ? null : node.node_id)
|
||||
}
|
||||
>
|
||||
{node.label}
|
||||
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<div
|
||||
className="mt-3 space-y-2 rounded-lg border border-[var(--surface-divider)] p-3"
|
||||
aria-label={`节点详情 ${selected.label}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
|
||||
{renderDetails(selected)}
|
||||
{onAsk ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onAsk(selected)}>
|
||||
围绕此节点提问
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <section aria-label="任务报告" className="surface-reading min-w-0 space-y-3 rounded-xl border border-[var(--surface-divider)]/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
|
||||
{report ? <StatusBadge tone={insufficient ? "warning" : "neutral"}>
|
||||
{insufficient ? "证据不足" : report.outcome === "concluded" ? "已形成结论" : "结果未记录"}
|
||||
</StatusBadge> : null}
|
||||
</div>
|
||||
{report ? <>
|
||||
<div className={`border-l-2 pl-3 ${insufficient ? "border-[var(--status-warning-mark)]" : "border-[var(--palette-brand)]"}`}>
|
||||
<p className="text-xs font-semibold leading-5 text-slate-900">
|
||||
{!current ? reportLabel : insufficient ? insufficientLabel : report.stage === "interim" ? "阶段性判断" : report.outcome === "concluded" ? "调查结论" : "历史报告内容"}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-xs leading-6 text-slate-700">{report.current_conclusion}</p>
|
||||
{insufficient ? <p className="mt-1 break-words text-xs leading-5 text-[var(--status-warning-foreground)]">结束原因:{report.stop_reason}</p> : null}
|
||||
</div>
|
||||
{report.data_gaps && report.data_gaps.length > 0 ? <div aria-label="数据缺口" className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-slate-800">{insufficient ? "关键数据缺口" : "数据范围限制"}</h4>
|
||||
{report.data_gaps.map((gap) => <div key={gap.gap_id} className="space-y-1 border-l border-[var(--surface-divider)] pl-3 text-xs leading-5">
|
||||
<p className="break-words font-medium text-slate-900">{gap.missing_data}{gap.blocking ? " · 阻止形成结论" : ""}</p>
|
||||
<p className="break-words text-slate-600">查询范围:{gap.scope}</p>
|
||||
<p className="break-words text-slate-600">判断影响:{gap.impact}</p>
|
||||
<p className="break-words text-slate-600">需补齐:{gap.required_data}</p>
|
||||
<p className="break-words text-[var(--ink-muted)]">依据:{gap.evidence_refs.map((id) => task.evidence.find((item) => item.evidence_id === id)?.summary ?? id).join(";")}</p>
|
||||
</div>)}
|
||||
</div> : report.data_gaps == null ? <p className="text-[11px] leading-5 text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p> : null}
|
||||
{!insufficient && report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
|
||||
{!insufficient && report.pending_items.length > 0 ? <ReportItems title="待确认事项" items={report.pending_items} /> : null}
|
||||
{insufficient ? <p className="text-xs leading-5 text-slate-600">补齐上述数据后可发起新一轮调查。原投诉工单与现场处置进度独立记录。</p> : null}
|
||||
<div className="space-y-1 border-t border-[var(--surface-divider)]/70 pt-2 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
<p className="break-words">结论边界:{report.conclusion_boundary}</p>
|
||||
<p>报告第 {snapshot.revision} 版 · {new Date(snapshot.published_at).toLocaleString("zh-CN", { hour12: false })}</p>
|
||||
</div>
|
||||
</> : <p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ReportItems({ title, items }: { title: string; items: string[] }) {
|
||||
return <div><h4 className="text-xs font-semibold text-slate-800">{title}</h4>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4 text-xs leading-5 text-slate-700">{items.map((item, index) => <li key={index} className="break-words">{item}</li>)}</ul>
|
||||
</div>;
|
||||
}
|
||||
@@ -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<OperationalTaskReportPage>;
|
||||
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<ReportSelection | null>(() =>
|
||||
task.latest_report
|
||||
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
|
||||
: null
|
||||
);
|
||||
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
|
||||
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(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<HTMLDetailsElement>("details[data-review-action]")
|
||||
].find((item) => item.dataset.reviewAction === focusActionId);
|
||||
if (details) {
|
||||
details.open = true;
|
||||
details.scrollIntoView?.({ block: "nearest" });
|
||||
details.querySelector<HTMLElement>("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 (
|
||||
<article aria-label="调查任务报告" className="investigation-report surface-reading">
|
||||
<header className="investigation-report-header">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-[var(--ink-muted)]">
|
||||
调查任务 · 第 {task.execution_generation} 轮
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold leading-6 text-[var(--ink)]">{task.title}</h2>
|
||||
<StatusBadge
|
||||
tone={
|
||||
task.lifecycle_status === "failed"
|
||||
? "danger"
|
||||
: currentInsufficient || pendingActions.length
|
||||
? "warning"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{investigationStatus(task)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{task.summary}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
|
||||
}
|
||||
>
|
||||
<MessageSquare size={14} aria-hidden />
|
||||
Agent 协作
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{latestAvailable ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
|
||||
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
|
||||
查看新报告
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section aria-label="任务报告" className="investigation-report-section">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
|
||||
<FileText size={15} aria-hidden />
|
||||
当前结果
|
||||
</h3>
|
||||
{report ? (
|
||||
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
|
||||
{insufficient
|
||||
? "证据不足"
|
||||
: report.outcome === "concluded"
|
||||
? "已形成判断"
|
||||
: "结果未记录"}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
{report && snapshot ? (
|
||||
<>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
|
||||
{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"
|
||||
? "调查结论"
|
||||
: "历史报告内容"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
结论边界:{report.conclusion_boundary}
|
||||
</p>
|
||||
{insufficient ? (
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
|
||||
结束原因:{report.stop_reason}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-[var(--ink-muted)]">
|
||||
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
|
||||
{dateLabel(snapshot.published_at)}
|
||||
</p>
|
||||
{!insufficient &&
|
||||
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
|
||||
<Disclosure label="判断说明与建议" className="mt-2">
|
||||
{report.recommendations.length > 0 ? (
|
||||
<ReportItems title="处置建议" items={report.recommendations} />
|
||||
) : null}
|
||||
{report.pending_items.length > 0 ? (
|
||||
<ReportItems title="待核实问题" items={report.pending_items} />
|
||||
) : null}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
|
||||
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pendingActions.length > 0 ? (
|
||||
<section aria-label="当前需处理" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">
|
||||
当前需处理{" "}
|
||||
<span className="font-normal text-[var(--ink-muted)]">
|
||||
· {pendingActions.length} 项
|
||||
</span>
|
||||
</h3>
|
||||
{previousRound || olderRevision ? (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
下列操作使用当前草稿与审批状态。
|
||||
</p>
|
||||
) : null}
|
||||
{pendingActions.map((action) => (
|
||||
<Disclosure
|
||||
key={action.action_id}
|
||||
data-review-action={action.action_id}
|
||||
initiallyOpen={action.action_id === focusActionId}
|
||||
label={
|
||||
<span>
|
||||
{actionTitle(action)} ·{" "}
|
||||
{action.approval_status === "pending"
|
||||
? "待审核"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{renderAction(action)}
|
||||
</Disclosure>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{completedActions.length > 0 ? (
|
||||
<section aria-label="处置进展" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">
|
||||
处置进展{" "}
|
||||
<span className="font-normal text-[var(--ink-muted)]">
|
||||
· {completedActions.length} 项
|
||||
</span>
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
{previousRound || olderRevision
|
||||
? "以下为任务当前动作状态,与所选历史报告分开记录。"
|
||||
: "动作回执只说明对应系统操作结果,不替代后续现场事实。"}
|
||||
</p>
|
||||
{completedActions.map((action) => (
|
||||
<Disclosure
|
||||
key={action.action_id}
|
||||
label={
|
||||
<span className="investigation-action-summary">
|
||||
<span>{actionTitle(action)} · {actionProgressLabel(action)}</span>
|
||||
<span className="investigation-action-summary-detail">
|
||||
{action.receipt?.detail ?? action.execution_detail ?? action.reason}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">{renderAction(action)}</div>
|
||||
</Disclosure>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{report?.data_gaps?.length ? (
|
||||
<section aria-label="数据缺口" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">
|
||||
{insufficient ? "关键数据缺口" : "数据范围限制"}
|
||||
</h3>
|
||||
{report.data_gaps.map((gap) => (
|
||||
<div key={gap.gap_id} className="mt-3">
|
||||
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
|
||||
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4">
|
||||
<Disclosure label="查看查询依据">
|
||||
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
<p>查询范围:{gap.scope}</p>
|
||||
<p>需补齐:{gap.required_data}</p>
|
||||
{gap.evidence_refs.map((id) => (
|
||||
<EvidenceReference
|
||||
key={id}
|
||||
id={id}
|
||||
task={task}
|
||||
renderEvidence={renderEvidence}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
discuss(
|
||||
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
|
||||
{ gap_id: gap.gap_id }
|
||||
)
|
||||
}
|
||||
>
|
||||
补充事实
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{insufficient ? (
|
||||
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
{previousRound || olderRevision
|
||||
? "此处展示所选历史报告的数据缺口,当前调查状态见顶部。"
|
||||
: "补齐事实后发起新一轮调查。原投诉工单与现场处置进度独立记录。"}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : report?.data_gaps == null && report ? (
|
||||
<p className="px-4 py-2 text-xs text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p>
|
||||
) : null}
|
||||
|
||||
{diagram ? (
|
||||
<InvestigationDiagramView
|
||||
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
|
||||
diagram={diagram}
|
||||
renderDetails={(node) => (
|
||||
<>
|
||||
{node.evidence_refs.map((id) => (
|
||||
<EvidenceReference key={id} id={id} task={task} renderEvidence={renderEvidence} />
|
||||
))}
|
||||
{node.action_refs.map((id) => {
|
||||
const action = task.actions.find((item) => item.action_id === id);
|
||||
return action ? (
|
||||
<p key={id} className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
关联动作:{actionTitle(action)} ·{" "}
|
||||
{action.approval_status === "approved"
|
||||
? "已批准"
|
||||
: action.approval_status === "rejected"
|
||||
? "已拒绝"
|
||||
: "待审核"}{" "}
|
||||
·{" "}
|
||||
{action.execution_status === "succeeded"
|
||||
? "已有执行回执"
|
||||
: "当前执行结果未确认成功"}
|
||||
</p>
|
||||
) : null;
|
||||
})}
|
||||
{!node.evidence_refs.length && !node.action_refs.length ? (
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
此处说明核查方向,尚无关联证据或业务动作记录。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
onAsk={(node) =>
|
||||
discuss(
|
||||
`请解释调查脉络中的“${node.label}”及其关联依据。`,
|
||||
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section aria-label="主要依据" className="investigation-report-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
|
||||
{majorEvidence.map((item) => (
|
||||
<div key={item.evidence_id} className="mt-2">
|
||||
{renderEvidence(item)}
|
||||
<button
|
||||
type="button"
|
||||
className="investigation-text-action"
|
||||
onClick={() =>
|
||||
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
|
||||
evidence_id: item.evidence_id
|
||||
})
|
||||
}
|
||||
>
|
||||
围绕此证据提问
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(snapshot ? evidence : task.evidence).length === 0 ? (
|
||||
<p className="mt-2 text-sm text-[var(--ink-muted)]">尚无可展示的引用证据。</p>
|
||||
) : null}
|
||||
{remainingEvidence.length > 0 ? (
|
||||
<Disclosure label={`更多引用依据 · ${remainingEvidence.length} 条`} className="mt-2">
|
||||
{remainingEvidence.map((item) => (
|
||||
<div key={item.evidence_id}>
|
||||
{renderEvidence(item)}
|
||||
<button
|
||||
type="button"
|
||||
className="investigation-text-action"
|
||||
onClick={() =>
|
||||
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
|
||||
evidence_id: item.evidence_id
|
||||
})
|
||||
}
|
||||
>
|
||||
围绕此证据提问
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
{snapshot &&
|
||||
task.evidence.some((item) => !report?.evidence_refs.includes(item.evidence_id)) ? (
|
||||
<Disclosure label="其他已保存资料" className="mt-2">
|
||||
{task.evidence
|
||||
.filter((item) => !report?.evidence_refs.includes(item.evidence_id))
|
||||
.map(renderEvidence)}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="investigation-report-section">
|
||||
<Disclosure label="任务时间线">
|
||||
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
|
||||
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
|
||||
</p>
|
||||
<ol aria-label="任务时间线记录" className="investigation-timeline">
|
||||
{timeline.map((item) => (
|
||||
<li key={item.id} className="investigation-timeline-item">
|
||||
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
|
||||
{dateLabel(item.occurredAt)}
|
||||
</time>
|
||||
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Disclosure>
|
||||
{onLoadReports ? (
|
||||
<Disclosure
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<History size={14} aria-hidden />
|
||||
报告版本
|
||||
</span>
|
||||
}
|
||||
onToggle={(event) => {
|
||||
if (event.currentTarget.open && history.length === 0 && !historyLoading)
|
||||
void loadHistory(0);
|
||||
}}
|
||||
>
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.revision}
|
||||
type="button"
|
||||
className="investigation-history-row"
|
||||
aria-pressed={snapshot?.revision === item.revision}
|
||||
onClick={() => selectReport(item)}
|
||||
>
|
||||
<span>
|
||||
{roundLabel(item)} · 报告第 {item.revision} 版
|
||||
{item.revision === task.latest_report?.revision ? " · 最新版" : ""}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--ink-muted)]">
|
||||
{item.report.outcome === "insufficient_data"
|
||||
? "证据不足"
|
||||
: item.report.stage === "interim"
|
||||
? "阶段性判断"
|
||||
: item.report.outcome === "concluded"
|
||||
? "已形成判断"
|
||||
: "历史记录"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{historyError ? (
|
||||
<p role="alert" className="text-xs leading-5 text-[var(--status-warning-foreground)]">
|
||||
{historyError}
|
||||
</p>
|
||||
) : null}
|
||||
{historyLoading ? (
|
||||
<p className="py-2 text-xs text-[var(--ink-muted)]">正在读取报告版本。</p>
|
||||
) : historyOffset != null || historyError ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadHistory(historyError ? 0 : historyOffset!)}
|
||||
>
|
||||
{historyError ? "重新加载" : "更多报告"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Disclosure>
|
||||
) : null}
|
||||
<p className="mt-3 text-xs text-[var(--ink-muted)]">
|
||||
任务更新于 {dateLabel(task.updated_at)}
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--ink-muted)]">关联证据当前不可用。</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportItems({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="py-2 text-sm leading-6 text-[var(--ink)]">
|
||||
<p className="font-medium">{title}</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -120,6 +120,9 @@ type ConditionDetailPanelProps = {
|
||||
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onTaskRefresh?: () => void;
|
||||
onLoadTaskReports?: (taskId: string, offset: number) => Promise<import("../api/operational-task-client").OperationalTaskReportPage>;
|
||||
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 (
|
||||
<StatusBadge
|
||||
tone={
|
||||
isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
|
||||
insufficient ? "warning" : isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
|
||||
}
|
||||
activity={
|
||||
isAgentEvent ? "static" : getStatusActivity(badgeStatus)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
|
||||
{insufficient ? "已结束 · 证据不足" : isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
@@ -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<SubmittedIncidentWorkOrder | null>(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 (
|
||||
<DetailScroll>
|
||||
<DetailShell>
|
||||
<DetailHeader
|
||||
{!investigation ? <DetailHeader
|
||||
condition={condition}
|
||||
icon={header.icon}
|
||||
iconClassName={header.iconClassName}
|
||||
iconSpin={header.iconSpin}
|
||||
hideDetail={condition.operationalTask?.task_type === "external_work_order"}
|
||||
/>
|
||||
/> : null}
|
||||
<DetailDecisionBody>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
@@ -290,6 +310,9 @@ function ConditionRecordDetailPanel({
|
||||
<>
|
||||
<AgentExecutionPlanPanel
|
||||
task={condition.operationalTask}
|
||||
onLoadReports={onLoadTaskReports ? (offset) => 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("当前未配置人工确认接口。"));
|
||||
|
||||
@@ -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<void>;
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -91,12 +93,15 @@ type ScheduledConditionFeedProps = {
|
||||
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onTaskRefresh?: () => void;
|
||||
onLoadTaskReports?: (taskId: string, offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (taskId: string, generation: number, revision: number) => void;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
data-task-category={taskCategory}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto overflow-hidden p-3 motion-reduce:transition-none",
|
||||
@@ -506,14 +517,14 @@ export function ScheduledConditionFeed({
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
{activeLoading && expanded ? (
|
||||
{activeLoading && expanded && !selectedCondition ? (
|
||||
<ConditionDetailSkeleton />
|
||||
) : expanded && selectedCondition ? (
|
||||
<ConditionDetailPanel
|
||||
condition={selectedCondition}
|
||||
dataSource={dataSource}
|
||||
workOrderDataSource={workOrderDataSource}
|
||||
onContinueConversation={() => 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}
|
||||
</div>
|
||||
@@ -1098,14 +1112,15 @@ function ConditionTimelineRow({
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold leading-4">
|
||||
<span className="scheduled-condition-title block truncate text-xs font-semibold leading-4">
|
||||
{condition.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
|
||||
{condition.summary}
|
||||
</span>
|
||||
</span>
|
||||
<StatusPill condition={condition} className="shrink-0 text-xs" />
|
||||
<StatusPill condition={condition} className="scheduled-condition-status shrink-0 text-xs" />
|
||||
{condition.kind === "condition" && condition.hasNewReport ? <span className="text-xs font-medium text-[var(--action-blue)]">新报告</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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 <section aria-label="当前任务审核" className="surface-reading space-y-2 rounded-xl border border-[var(--surface-border)]/80 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={14} aria-hidden="true" />当前任务审核 · {task.title}</div>
|
||||
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
|
||||
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
|
||||
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
|
||||
{recentFacts.length > 0 ? <details>
|
||||
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
|
||||
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
|
||||
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
|
||||
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
|
||||
</li>)}</ul>
|
||||
</details> : null}
|
||||
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
|
||||
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
|
||||
canDecide={task.permissions.can_decide_access}
|
||||
|
||||
@@ -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("报告引用未记录。");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
todo_update: Record<string, unknown>;
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string>());
|
||||
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";
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
|
||||
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
|
||||
const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null);
|
||||
const [registry, setRegistry] = useState<UIRegistry | null>(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<Record<string, string>> => {
|
||||
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<AgentUiMessage>({
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 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);
|
||||
if (notification?.status === "unread") {
|
||||
void operationalTasks.acknowledgeNotification(notificationId, notification.version);
|
||||
}
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -134,6 +134,7 @@ export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledCondition
|
||||
kpis?: ScheduledConditionKpi[];
|
||||
report?: ScheduledConditionReport;
|
||||
operationalTask?: OperationalTask;
|
||||
hasNewReport?: boolean;
|
||||
};
|
||||
|
||||
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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, "#lt;")
|
||||
.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))
|
||||
);
|
||||
}
|
||||
+239
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(`<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body>
|
||||
<main id="style-comparison" style="padding:32px;background:#EFF2F7;min-height:100vh;color:#26334D;font-family:'Noto Sans SC Variable',sans-serif">
|
||||
<h1 style="font-size:22px;font-weight:600;margin-bottom:8px">调查脉络 · 三种视觉表达</h1>
|
||||
<p style="font-size:13px;color:#68778E;margin-bottom:24px">使用截图中的节点与关系制作的样式设计示例</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px">
|
||||
<section><h2 style="font-size:16px;font-weight:600">A · 工程流程</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">白底、分工分组、明确的节点和连线层次</p><div id="engineering"></div></section>
|
||||
<section><h2 style="font-size:16px;font-weight:600">B · 墨色报告</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">黑白、直角、虚实线区分分工,适合打印</p><div id="ink"></div></section>
|
||||
<section><h2 style="font-size:16px;font-weight:600">C · 手绘白板</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">纸色、手绘线条,适合讲解设计思路</p><div id="sketch"></div></section>
|
||||
</div>
|
||||
</main><style>
|
||||
#style-comparison [data-streamdown="mermaid"], #style-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:transparent;}
|
||||
#style-comparison [data-streamdown="mermaid"] [role="img"] {width:100%;}
|
||||
#style-comparison .font-mono {display:none;}
|
||||
</style></body></html>`);
|
||||
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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user