From 87cc7785da5327503b2c0dfa7449b838613da0ef Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 24 Sep 2026 16:36:12 +0800 Subject: [PATCH] feat(workbench): refine dynamic task collaboration --- docs/component-design-system.md | 1 + .../agent/components/agent-command-panel.tsx | 3 + src/features/agent/execution-plan.ts | 9 +- .../api/operational-task-client.test.ts | 51 +++- .../workbench/api/operational-task-client.ts | 67 ++++- .../agent-execution-plan-panel.test.tsx | 91 ++++-- .../components/agent-execution-plan-panel.tsx | 256 +++++++++++------ .../scheduled-condition-detail-panel.test.tsx | 24 +- .../scheduled-condition-detail-panel.tsx | 270 +++++++++++++----- .../components/scheduled-condition-feed.tsx | 101 +------ .../components/task-conversation-review.tsx | 33 +++ .../hooks/use-operational-tasks.test.ts | 4 +- .../workbench/hooks/use-operational-tasks.ts | 57 +++- .../workbench/hooks/use-workbench-agent.ts | 5 + .../hooks/use-workbench-sync.test.ts | 31 ++ .../workbench/hooks/use-workbench-sync.ts | 44 ++- src/features/workbench/map-workbench-page.tsx | 66 ++--- .../managed-test-resources.tsx | 246 ++++++++++------ src/features/workbench/types.ts | 2 + .../browser/dynamic-investigation-flow.e2e.ts | 181 ++++++++++++ tests/browser/mobile-workbench-sheet.e2e.ts | 55 ++-- .../browser/operational-task-approval.e2e.ts | 20 +- tests/browser/peripheral-sample-panel.e2e.ts | 57 +++- .../scheduled-condition-acrylic.e2e.ts | 8 +- .../browser/workbench-runtime-backend.e2e.ts | 4 +- 25 files changed, 1216 insertions(+), 470 deletions(-) create mode 100644 src/features/workbench/components/task-conversation-review.tsx create mode 100644 src/features/workbench/hooks/use-workbench-sync.test.ts create mode 100644 tests/browser/dynamic-investigation-flow.e2e.ts diff --git a/docs/component-design-system.md b/docs/component-design-system.md index 069f40c..5488df5 100644 --- a/docs/component-design-system.md +++ b/docs/component-design-system.md @@ -41,6 +41,7 @@ ## 7. 使用约束 - 必须优先显示当前状态和可执行的下一步。 +- 调查计划由 Agent 动态发布。默认呈现本次选中的路线和当前待办,未选路线及旧修订按需展开;条件、并行和审核状态只读取明确的结构化字段。 - 必须明确 synthetic、只读、结果未知和未配置状态。 - 必须保留完整请求号、对象修订和证据来源的可读入口。 - 不用颜色代替文字状态。 diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index 9166d1d..f965132 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -118,6 +118,7 @@ type AgentCommandPanelProps = { selectedModel?: string; approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; + reviewPanel?: ReactNode; workspaceContext?: AgentWorkspaceContextItem[]; prompt: string; onCollapse: () => void; @@ -173,6 +174,7 @@ export function AgentCommandPanel({ selectedModel = "", approvalMode = "request", uiResults = [], + reviewPanel, workspaceContext = [], prompt, onCollapse, @@ -585,6 +587,7 @@ export function AgentCommandPanel({ {!qaOnly ? : null} + {reviewPanel} { const ids = new Set(plan.nodes.map((node) => node.id)); return ids.size === plan.nodes.length && plan.edges.every((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to); diff --git a/src/features/workbench/api/operational-task-client.test.ts b/src/features/workbench/api/operational-task-client.test.ts index b6663cc..7765278 100644 --- a/src/features/workbench/api/operational-task-client.test.ts +++ b/src/features/workbench/api/operational-task-client.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseOperationalTask } from "./operational-task-client"; +import { + parseOperationalTask, + parseOperationalTaskNotification +} from "./operational-task-client"; vi.mock("./project-client", () => ({ findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })), @@ -49,6 +52,17 @@ describe("operational task contract", () => { it("accepts a task without any plan or report", () => { expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull(); }); + it("accepts a withdrawn peripheral source timestamp", () => { + const value = task(); + expect(parseOperationalTask({ + ...value, + source: { ...value.source, deleted_at: time } + }).source.deleted_at).toBe(time); + expect(() => parseOperationalTask({ + ...value, + source: { ...value.source, deleted_at: "not-a-date" } + })).toThrow(); + }); it("keeps unknown fields and invalid generations rejected", () => { expect(() => parseOperationalTask(task(0))).toThrow(); const value = task(); @@ -57,3 +71,38 @@ describe("operational task contract", () => { expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow(); }); }); + +describe("operational notification contract", () => { + const notification = { + notification_id: id, + task_id: id, + kind: "action_approval_pending", + title: "待审批工单", + description: "请审核派单内容", + required_permission: null, + status: "unread", + version: 1, + created_at: time, + read_at: null, + read_by: null + }; + + it("keeps legacy notifications active by default", () => { + expect(parseOperationalTaskNotification(notification)).toMatchObject({ + lifecycle_status: "active", + subject_type: "task", + subject_id: null + }); + }); + + it("accepts resolved notifications with a business subject", () => { + expect(parseOperationalTaskNotification({ + ...notification, + lifecycle_status: "resolved", + subject_type: "action", + subject_id: id, + resolved_at: time, + resolution_reason: "action_approved" + }).lifecycle_status).toBe("resolved"); + }); +}); diff --git a/src/features/workbench/api/operational-task-client.ts b/src/features/workbench/api/operational-task-client.ts index 72834b3..010ee94 100644 --- a/src/features/workbench/api/operational-task-client.ts +++ b/src/features/workbench/api/operational-task-client.ts @@ -10,7 +10,8 @@ const taskSourceSchema = z.object({ kind: z.enum(["schedule", "external_system", "operator", "agent"]), external_id: z.string().min(1).nullable(), external_revision: z.number().int().positive().nullable(), - event_kind: z.string().min(1).nullable() + event_kind: z.string().min(1).nullable(), + deleted_at: workbenchDateTimeSchema.nullish() }).strict(); const taskTriggerSchema = z.object({ kind: z.enum(["schedule", "external_sync", "event", "manual"]), @@ -78,14 +79,19 @@ const planNodeSchema = z.object({ status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]), detail: z.string().min(1), evidence_refs: z.array(z.string().min(1)), - action_refs: z.array(z.string().uuid()) + action_refs: z.array(z.string().uuid()), + kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).nullish(), + selected_edge_id: z.string().min(1).nullish(), + gate_ref: z.string().uuid().nullish(), + revisits_node_id: z.string().min(1).nullish() }).strict(); const planInputSchema = z.object({ - schema: z.literal("execution-plan@1"), + schema: z.enum(["execution-plan@1", "execution-plan@2"]), title: z.string().min(1), reason: z.string().min(1), nodes: z.array(planNodeSchema).min(1).max(100), edges: z.array(z.object({ + id: z.string().min(1).nullish(), from: z.string().min(1), to: z.string().min(1), condition: z.string().min(1).nullable() @@ -139,6 +145,8 @@ const actionSchema = z.object({ execution_request_id: z.string().uuid().nullable(), proposed_by: z.string().min(1), proposed_at: workbenchDateTimeSchema, + edited_by: z.string().min(1).nullish(), + edited_at: workbenchDateTimeSchema.nullish(), decided_by: z.string().min(1).nullable(), decided_at: workbenchDateTimeSchema.nullable(), decision_comment: z.string().min(1).nullable(), @@ -147,6 +155,21 @@ const actionSchema = z.object({ receipt: actionReceiptSchema.nullable(), execution_detail: z.string().min(1).nullable() }).strict(); +const accessRequestSchema = z.object({ + access_request_id: z.string().uuid(), + task_id: z.string().uuid(), + node_id: z.string().min(1), + scopes: z.array(z.string().min(1)), + reason: z.string().min(1), + status: z.enum(["pending", "approved", "rejected"]), + approved_scopes: z.array(z.string().min(1)), + version: z.number().int().positive(), + requested_by: z.string().min(1), + requested_at: workbenchDateTimeSchema, + decided_by: z.string().min(1).nullable(), + decided_at: workbenchDateTimeSchema.nullable(), + comment: z.string().min(1).nullable() +}).strict(); const taskPermissionsSchema = z.object({ can_publish_artifacts: z.boolean(), can_propose_actions: z.boolean(), @@ -154,7 +177,8 @@ const taskPermissionsSchema = z.object({ can_decide_work_orders: z.boolean(), can_execute_work_orders: z.boolean(), can_decide_commands: z.boolean(), - can_execute_commands: z.boolean() + can_execute_commands: z.boolean(), + can_decide_access: z.boolean().default(false) }).strict(); const externalWorkOrderActivitySchema = z.object({ sequence: z.number().int().positive(), @@ -192,6 +216,7 @@ const operationalTaskSchema = taskSummarySchema.extend({ latest_report: reportRevisionSchema.nullable(), evidence: z.array(evidenceSchema), actions: z.array(actionSchema), + access_requests: z.array(accessRequestSchema).default([]), permissions: taskPermissionsSchema, work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null) }).strict(); @@ -212,6 +237,11 @@ const notificationSchema = z.object({ ]), title: z.string().min(1), description: z.string().min(1), + 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), + resolved_at: workbenchDateTimeSchema.nullable().default(null), + resolution_reason: z.string().min(1).nullable().default(null), status: z.enum(["unread", "read"]), version: z.number().int().positive(), required_permission: z.string().min(1).nullable(), @@ -252,6 +282,7 @@ export type OperationalTaskSummary = z.infer; export type OperationalTask = z.infer; export type OperationalTaskPlan = z.infer; export type OperationalTaskAction = z.infer; +export type OperationalTaskAccessRequest = z.infer; export type OperationalTaskEvidence = z.infer; export type OperationalTaskNotification = z.infer; export type OperationalTaskSchedule = z.infer; @@ -336,6 +367,34 @@ export async function executeOperationalTaskAction(taskId: string, actionId: str )); } +export async function updateOperationalWorkOrderDraft(taskId: string, actionId: string, input: { + request_id: string; expected_version: number; payload: Record; +}, getAccessToken?: AccessTokenProvider) { + return actionSchema.parse(await operationalTaskRequest( + `/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/draft`, + getAccessToken, { method: "PATCH", body: JSON.stringify(input) } + )); +} + +export async function confirmOperationalWorkOrderDispatch(taskId: string, actionId: string, input: { + request_id: string; expected_version: number; +}, getAccessToken?: AccessTokenProvider) { + return actionSchema.parse(await operationalTaskRequest( + `/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/confirm-dispatch`, + getAccessToken, { method: "POST", body: JSON.stringify(input) } + )); +} + +export async function decideOperationalTaskAccess(taskId: string, accessRequestId: string, input: { + request_id: string; expected_version: number; decision: "approved" | "rejected"; + approved_scopes: string[]; comment?: string; +}, getAccessToken?: AccessTokenProvider) { + return accessRequestSchema.parse(await operationalTaskRequest( + `/operational-tasks/${encodeURIComponent(taskId)}/access-requests/${encodeURIComponent(accessRequestId)}/decision`, + getAccessToken, { method: "POST", body: JSON.stringify(input) } + )); +} + export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) { return notificationPageSchema.parse(await operationalTaskRequest( "/operational-task-notifications?unread_only=true&limit=100&offset=0", diff --git a/src/features/workbench/components/agent-execution-plan-panel.test.tsx b/src/features/workbench/components/agent-execution-plan-panel.test.tsx index 34cb7a9..67ca7af 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.test.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.test.tsx @@ -6,7 +6,7 @@ import type { OperationalTask } from "../api/operational-task-client"; import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel"; describe("execution step cards", () => { - it("shows parallel cards, exact join dependencies and escaped model content", () => { + it("shows exact historical dependencies without assigning an unrecorded branch type", () => { const plan: AgentExecutionPlan = { schema_version: "execution-plan@1", session_id: "s1", revision: 1, updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验", @@ -17,19 +17,15 @@ describe("execution step cards", () => { edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }] }; const { container } = render(); - expect(screen.getByText("并行处理")).toBeInTheDocument(); - expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch"); - expect(screen.getByText("2 条独立分枝")).toBeInTheDocument(); + expect(screen.getByText(/历史流程按原始依赖展示/)).toBeInTheDocument(); + expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument(); expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4); expect(container.querySelector("script")).toBeNull(); - expect(screen.queryByText(/前置:/)).not.toBeInTheDocument(); - expect(screen.getByText("分枝 1")).toBeInTheDocument(); - expect(screen.getByText("分枝 2")).toBeInTheDocument(); - expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument(); + expect(screen.getByText("前置:上游(上游正常)、邻区(投诉集中)")).toBeInTheDocument(); expect(screen.getByText("待人工/资料")).toBeInTheDocument(); }); - it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => { + it("keeps all historical nodes and their recorded dependencies", () => { const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({ id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: [] }); @@ -58,11 +54,10 @@ describe("execution step cards", () => { }; const { container } = render(); - expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible(); - expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible(); - expect(screen.getAllByText("分枝汇合")).toHaveLength(2); + expect(screen.getByText("前置:上游复核、邻区投诉、阀门排查、运维记录")).toBeVisible(); + expect(screen.getByText("前置:水箱记录、现场采样")).toBeVisible(); + expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument(); expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length); - expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument(); }); }); @@ -96,7 +91,8 @@ const taskBase = { }, evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }], actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }], - permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }, + permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false, can_decide_access: false }, + access_requests: [], work_order_snapshot: null } as OperationalTask; @@ -111,11 +107,74 @@ function taskPanel(task: OperationalTask, overrides: Partial { throw new Error("no snapshot"); }), ...overrides }; - render(); - return props; + const { rerender } = render(); + return { ...props, rerender }; } describe("operational task approval and evidence", () => { + it("shows the selected v2 route first and keeps unchosen work available", () => { + const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({ + id, title, status, owner: "agent" as const, detail: title + }); + const plan = { + nodes: [ + { ...node("risk", "判断紧急风险", "completed"), kind: "decision" as const, selected_edge_id: "urgent" }, + { ...node("urgent", "立即现场采样", "waiting"), kind: "activity" as const }, + { ...node("routine", "常规核查", "skipped"), kind: "activity" as const } + ], + edges: [ + { id: "urgent", from: "risk", to: "urgent", label: "有紧急风险" }, + { id: "routine", from: "risk", to: "routine", label: "无紧急风险" } + ] + }; + render(); + expect(screen.getByText(/路线选择:/).parentElement).toHaveTextContent("有紧急风险"); + expect(screen.getByText("其他规划路线 1 项")).toBeVisible(); + expect(screen.getAllByText("立即现场采样")[0]).toBeVisible(); + }); + + it("groups nested parallel work at its recorded joins", () => { + const node = (id: string, kind: "activity" | "decision" | "parallel_split" | "parallel_join") => ({ + id, title: id, detail: `${id}详情`, owner: "agent" as const, status: "completed" as const, kind + }); + const plan = { + nodes: [node("start", "parallel_split"), node("field", "activity"), node("agent", "activity"), + node("read", "parallel_split"), node("upstream", "activity"), node("network", "activity"), + node("evidence", "parallel_join"), node("result", "parallel_join")], + edges: [ + { id: "e1", from: "start", to: "field", condition: "现场" }, + { id: "e2", from: "start", to: "agent", condition: "Agent" }, + { id: "e3", from: "field", to: "result" }, + { id: "e4", from: "agent", to: "read" }, + { id: "e5", from: "read", to: "upstream", condition: "上游" }, + { id: "e6", from: "read", to: "network", condition: "管网" }, + { id: "e7", from: "upstream", to: "evidence" }, + { id: "e8", from: "network", to: "evidence" }, + { id: "e9", from: "evidence", to: "result" } + ] + }; + const { container } = render(); + expect(screen.getAllByLabelText("并行处理 2 路")).toHaveLength(2); + 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); + }); + + 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" } }] }; + const props = taskPanel(initialTask, { onDraftUpdate, onConfirmDispatch }); + fireEvent.click(screen.getByRole("button", { name: "编辑草稿" })); + 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()); + props.rerender(); + 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", () => { taskPanel(taskBase); expect(screen.getByText("当前结论", { exact: true })).toBeVisible(); diff --git a/src/features/workbench/components/agent-execution-plan-panel.tsx b/src/features/workbench/components/agent-execution-plan-panel.tsx index 80ed1ba..104c9bf 100644 --- a/src/features/workbench/components/agent-execution-plan-panel.tsx +++ b/src/features/workbench/components/agent-execution-plan-panel.tsx @@ -33,9 +33,14 @@ type ExecutionNode = { status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled"; owner: "agent" | "human"; detail: string; + kind?: "activity" | "decision" | "parallel_split" | "parallel_join" | "gate" | null; + selected_edge_id?: string | null; + gate_ref?: string | null; + revisits_node_id?: string | null; }; type ExecutionEdge = { + id?: string | null; from: string; to: string; label?: string | null; @@ -52,6 +57,9 @@ type AgentExecutionPlanPanelProps = { onContinue: () => void; onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onExecute: (actionId: string, expectedVersion: number) => Promise; + onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record) => Promise; + onConfirmDispatch?: (actionId: string, expectedVersion: number) => Promise; + onAccessDecision?: (requestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise; onReconcile: (actionId: string, expectedVersion: number) => Promise; onLoadEvidenceSnapshot: (evidenceId: string) => Promise; onRefresh: () => void; @@ -62,6 +70,9 @@ export function AgentExecutionPlanPanel({ onContinue, onDecision, onExecute, + onDraftUpdate, + onConfirmDispatch, + onAccessDecision, onReconcile, onLoadEvidenceSnapshot, onRefresh @@ -103,8 +114,8 @@ export function AgentExecutionPlanPanel({ : "历史报告"; const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [ node.id, - task.actions.filter((action) => action.node_id === node.id).map((action) => ( - + task.actions.filter((action) => action.node_id === node.id).map((action) => ( + )) ])) : {}; const unlinkedActions = task.actions.filter((action) => !action.node_id || !plan?.nodes.some((node) => node.id === action.node_id)); @@ -138,6 +149,12 @@ export function AgentExecutionPlanPanel({ )} + {task.access_requests.length > 0 ?
+ {task.access_requests.map((request) => node.id === request.node_id)?.title} + canDecide={task.permissions.can_decide_access} onDecision={onAccessDecision} onRefresh={onRefresh} />)} +
: null} +

@@ -181,6 +198,8 @@ export function AgentExecutionPlanPanel({ action={action} onDecision={onDecision} onExecute={onExecute} + onDraftUpdate={onDraftUpdate} + onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} /> @@ -214,62 +233,85 @@ function ReportList({ title, items, tone }: { title: string; items: string[]; to } export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record }) { - return
; + return
{ + plan.nodes.some((node) => node.kind) + ? + : + }
; } -function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record; depth: number }) { - const layout = createBranchLayout(plan); - if (!layout || depth > 8) return ; - return ( -
- - {layout.branches.length > 0 ? ( -
-
- - 并行处理 - {layout.branches.length} 条独立分枝 -
-
- {layout.branches.map((branch, index) => { - const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id); - return ( -
node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5"> -

分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}

- -
- ); - })} -
-
- ) : null} - {layout.suffix.length > 0 ? ( -
-
分枝汇合
- node.id))} nodeActions={nodeActions} depth={depth + 1} /> -
- ) : null} +function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record }) { + const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); + const activeEdges = plan.edges.filter((edge) => { + const source = nodeById.get(edge.from); + return source?.kind !== "decision" || source.selected_edge_id === edge.id; + }); + const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)); + const visible = new Set(); + const queue = roots.map((node) => node.id); + while (queue.length > 0) { + const current = queue.shift()!; + if (visible.has(current)) continue; + visible.add(current); + for (const edge of activeEdges.filter((item) => item.from === current)) queue.push(edge.to); + } + const ordered = topologicalNodes(plan); + const alternatives = ordered.filter((node) => !visible.has(node.id)); + return
+
+ {roots.filter((node) => visible.has(node.id)).map((node) => )}
- ); + {alternatives.length > 0 ? +
    {alternatives.map((node) =>
  1. )}
+
: null} +
; } -function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record; compact?: boolean }) { - return
{nodes.map((node, index) => { - const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined; - return
{index > 0 ?
{edge?.condition ? {edge.condition} : null}
: null}
; - })}
; +function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: { + nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[]; + nodeById: Map; nodeActions: Record; seen: Set; +}) { + if (nodeId === stopId || seen.has(nodeId)) return null; + const node = nodeById.get(nodeId); + if (!node) return null; + const nextSeen = new Set(seen).add(nodeId); + const outgoing = activeEdges.filter((edge) => edge.from === nodeId); + 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; + return
+ + {node.kind === "decision" ?
+ 路线选择:{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"} +
{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}
+
: null} + {node.kind === "parallel_split" ?
+

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

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

+ +
)}
+
: outgoing[0] ? : null} + {join ? : null} +
; +} + +function findSemanticJoin(outgoing: ExecutionEdge[], activeEdges: ExecutionEdge[], nodeById: Map, stopId: string | null) { + if (outgoing.length < 2) return null; + const reachable = outgoing.map((edge) => reachableFrom(edge.to, new Map([...nodeById.keys()].map((id) => [id, activeEdges.filter((item) => item.from === id)])))); + for (const node of nodeById.values()) { + if (node.id === stopId) continue; + if (node.kind === "parallel_join" && reachable.every((items) => items.has(node.id))) return node; + } + return null; } function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record }) { - return
    {topologicalNodes(plan).map((node) =>
  1. )}
; -} - -function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView { - const included = new Set(nodeIds); - return { - nodes: plan.nodes.filter((node) => included.has(node.id)), - edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to)) - }; + const titleById = new Map(plan.nodes.map((node) => [node.id, node.title])); + return

历史流程按原始依赖展示;未标注路线或并行类型。

    {topologicalNodes(plan).map((node) => { + const prerequisites = plan.edges.filter((edge) => edge.to === node.id); + return
  1. {prerequisites.length > 0 ?

    前置:{prerequisites.map((edge) => `${titleById.get(edge.from) ?? edge.from}${edge.condition || edge.label ? `(${edge.condition ?? edge.label})` : ""}`).join("、")}

    : null}
  2. ; + })}
; } function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) { @@ -280,6 +322,7 @@ function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionN
{node.title}{label}
+ {node.kind && node.kind !== "activity" ?

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

: null}

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

{action ?
{action}
: null}
@@ -356,25 +399,70 @@ function EvidenceMeta({ label, value, mono = false }: { label: string; value: st return
{label}
{value}
; } -function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onRefresh }: { +export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: { + request: OperationalTask["access_requests"][number]; + nodeTitle?: string; + canDecide: boolean; + onDecision?: AgentExecutionPlanPanelProps["onAccessDecision"]; + onRefresh: () => void; +}) { + const [selected, setSelected] = useState(request.scopes); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const decide = async (decision: "approved" | "rejected") => { + if (!onDecision) return; + setBusy(true); setError(null); + try { + await onDecision(request.access_request_id, request.version, decision, decision === "approved" ? selected : []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "审核提交失败"); + if (/409|版本|conflict/i.test(String(nextError))) onRefresh(); + } finally { setBusy(false); } + }; + return
+

数据访问申请

{request.status === "approved" ? "已批准" : request.status === "rejected" ? "已拒绝" : "待审核"}
+

{request.reason}

+

关联步骤 {nodeTitle ?? request.node_id} · 申请于 {formatDateTime(request.requested_at)}

+
{request.scopes.map((scope) => )}
+ {request.status !== "pending" ?

{request.status === "approved" ? `已授权:${request.approved_scopes.map(dataScopeLabel).join("、")}` : "未授予数据访问权限"}

: null} + {request.status === "pending" && canDecide && onDecision ?
: null} + {request.status === "pending" && !canDecide ?

等待具有该数据源读取权限的项目成员审核。

: null} + {error ?

{error}

: null} +
; +} + +export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUpdate, onConfirmDispatch, onReconcile, onRefresh }: { task: OperationalTask; action: OperationalTaskAction; onDecision: AgentExecutionPlanPanelProps["onDecision"]; onExecute: AgentExecutionPlanPanelProps["onExecute"]; + onDraftUpdate?: AgentExecutionPlanPanelProps["onDraftUpdate"]; + onConfirmDispatch?: AgentExecutionPlanPanelProps["onConfirmDispatch"]; onReconcile: AgentExecutionPlanPanelProps["onReconcile"]; onRefresh: () => void; }) { - const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | null>(null); + const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | "save" | "confirm" | null>(null); const [error, setError] = useState(null); const [stale, setStale] = useState(false); const [rejectComment, setRejectComment] = useState(""); + const [editing, setEditing] = useState(false); + const [savedVersion, setSavedVersion] = useState(null); + const [draft, setDraft] = useState>(action.payload); const command = action.kind === "issue_command"; const 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") => { + const run = async (kind: "approve" | "reject" | "execute" | "reconcile" | "save" | "confirm") => { setSubmitting(kind); setError(null); try { - if (kind === "execute") await onExecute(action.action_id, action.version); + if (kind === "save") { + if (!onDraftUpdate) throw new Error("工单草稿编辑不可用"); + await onDraftUpdate(action.action_id, action.version, draft); + setSavedVersion(action.version); + setEditing(false); + } else if (kind === "confirm") { + if (!onConfirmDispatch) throw new Error("工单确认下发不可用"); + await onConfirmDispatch(action.action_id, action.version); + } else if (kind === "execute") await onExecute(action.action_id, action.version); else if (kind === "reconcile") await onReconcile(action.action_id, action.version); else { if (kind === "reject" && !rejectComment.trim()) { @@ -397,6 +485,15 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR

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

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

原因:{action.reason}

+ {editing && !command ?
+ setDraft({ ...draft, title: event.target.value })} /> + setDraft({ ...draft, description: event.target.value })} /> +
+ setDraft({ ...draft, location: event.target.value || null })} /> + setDraft({ ...draft, assignee: event.target.value || null })} /> + setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} /> +
+
: null} {action.evidence_refs.length > 0 ? (

确认依据

@@ -409,6 +506,7 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
) : null} {action.receipt ? : null} + {action.execution_detail && !action.receipt ?

{action.execution_detail}

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

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

{error ?

{error} 当前状态未按成功处理。

: null} @@ -416,9 +514,9 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR {!canDecide && action.approval_status === "pending" ?

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

: null} {action.approval_status === "pending" && canDecide ? setRejectComment(event.target.value)} /> : null}
- {action.approval_status === "pending" && canDecide ? <> : null} + {action.approval_status === "pending" && canDecide ? <>{!command && onDraftUpdate ? : null} : null} {action.approval_status === "approved" && action.execution_status === "not_submitted" ? : null} - {action.approval_status === "approved" && ["submitted", "unknown", "failed", "unsupported"].includes(action.execution_status) ? : null} + {action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? : null}
@@ -485,41 +583,6 @@ function externalStatusLabel(value: string) { return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record)[value] ?? value; } -function createBranchLayout(plan: ExecutionPlanView) { - const ordered = topologicalNodes(plan); - if (ordered.length !== plan.nodes.length) return null; - const outgoing = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.from === node.id)])); - const incoming = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.to === node.id)])); - const fork = ordered.find((node) => (outgoing.get(node.id)?.length ?? 0) > 1); - if (!fork) return { prefix: ordered, branches: [] as ExecutionNode[][], suffix: [] as ExecutionNode[] }; - const starts = outgoing.get(fork.id)!.map((edge) => edge.to); - const descendants = starts.map((start) => reachableFrom(start, outgoing)); - const join = ordered.find((node) => node.id !== fork.id && (incoming.get(node.id)?.length ?? 0) > 1 && descendants.every((set) => set.has(node.id))); - if (!join) return null; - const nodeById = new Map(plan.nodes.map((node) => [node.id, node])); - const branches: ExecutionNode[][] = []; - for (const start of starts) { - const branch: ExecutionNode[] = []; - let current = start; - const seen = new Set(); - while (current !== join.id && !seen.has(current)) { - seen.add(current); - const node = nodeById.get(current); - if (!node) return null; - branch.push(node); - const nextEdges = outgoing.get(current) ?? []; - if (nextEdges.length !== 1) return null; - current = nextEdges[0].to; - } - if (current !== join.id || branch.length === 0) return null; - branches.push(branch); - } - const branchIds = new Set(branches.flatMap((branch) => branch.map((node) => node.id))); - const forkIndex = ordered.findIndex((node) => node.id === fork.id); - const joinIndex = ordered.findIndex((node) => node.id === join.id); - return { prefix: ordered.slice(0, forkIndex + 1), branches, suffix: ordered.slice(joinIndex).filter((node) => !branchIds.has(node.id)) }; -} - function reachableFrom(start: string, outgoing: Map) { const seen = new Set(); const stack = [start]; @@ -546,9 +609,20 @@ function executionNodePresentation(node: ExecutionNode) { } function taskTypeLabel(type: OperationalTask["task_type"]) { return { scheduled_flow: "定时流程", external_work_order: "外部工单", event_investigation: "事件调查" }[type]; } +function dataScopeLabel(scope: string) { return ({ + workbench_runtime: "工作台运行数据", scada_devices: "SCADA 设备", scada_readings: "SCADA 监测读数", + peripheral_points: "外围监测点", peripheral_work_orders: "外围工单", + peripheral_observations: "外围监测记录", peripheral_test_data: "外围测试数据" +} as Record)[scope] ?? scope; } function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; } function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; } function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); } +function toLocalDateTime(value: unknown) { + if (typeof value !== "string") return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); +} function approvalLabel(status: OperationalTaskAction["approval_status"]) { return { pending: "待确认", approved: "已批准", rejected: "已拒绝" }[status]; } function approvalTone(status: OperationalTaskAction["approval_status"]): StatusTone { return status === "approved" ? "success" : status === "rejected" ? "danger" : "warning"; } function executionLabel(status: OperationalTaskAction["execution_status"]) { return { not_submitted: "未提交", submitted: "已提交", succeeded: "执行成功", failed: "执行失败", unsupported: "未接执行器", unknown: "结果未知" }[status]; } diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx index acb4348..098d1b7 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types"; -import { ConditionDetailPanel, WorkOrderActivityPanel } from "./scheduled-condition-detail-panel"; +import { ConditionDetailPanel, WorkOrderReportPanel } from "./scheduled-condition-detail-panel"; import { createVisibleTickerCards } from "./agent-task-ticker-utils"; import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts"; @@ -34,11 +34,12 @@ it("does not infer water-quality progress or conversation steps from elapsed tim describe("scheduled condition backend boundaries", () => { afterEach(cleanup); - it("shows a flat work order with its recorded activity", () => { + it("shows a work order report with explicit feedback and no activity log", () => { const workOrder: ScheduledWorkOrderItem = { id: "demo-work-order", kind: "work_order", code: "WO-DEMO", + externalStatus: "open", scheduledAt: "2026-09-23T10:00:00Z", title: "演示预约维修", summary: "验证中文时间标签", @@ -52,13 +53,24 @@ describe("scheduled condition backend boundaries", () => { location: "测试点", assignee: "维修班组", feedbackRequirements: ["回填处理结果"], - activity: [{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗", - kind: "created", title: "工单创建", detail: "确认预约窗口。" }] + activity: [ + { sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗", + kind: "created", title: "工单创建", detail: "确认预约窗口。" }, + { sequence: 2, occurredAt: "2026-09-23T11:00:00Z", actor: "维修班组", + kind: "feedback", title: "现场反馈", detail: "阀井检修已完成。" } + ] }; - render(); - expect(screen.getByText("工单创建")).toBeVisible(); + render(); + expect(screen.getByText("工单报告")).toBeVisible(); + expect(screen.getByText("工单编号:WO-DEMO")).toBeVisible(); + expect(screen.getByText("阀井检修已完成。")).toBeVisible(); expect(screen.getByText("回填处理结果")).toBeVisible(); + expect(screen.getByText("工单已分配,暂无独立的接单确认记录。")).toBeVisible(); + expect(screen.getByRole("list", { name: "工单进度" }).querySelectorAll("li")).toHaveLength(3); + expect(screen.queryByText("工单创建")).not.toBeInTheDocument(); + expect(screen.queryByText("活动记录")).not.toBeInTheDocument(); + expect(screen.queryByText("关键证据")).not.toBeInTheDocument(); expect(screen.queryByText("子任务")).not.toBeInTheDocument(); }); diff --git a/src/features/workbench/components/scheduled-condition-detail-panel.tsx b/src/features/workbench/components/scheduled-condition-detail-panel.tsx index 95ff96d..cb2c735 100644 --- a/src/features/workbench/components/scheduled-condition-detail-panel.tsx +++ b/src/features/workbench/components/scheduled-condition-detail-panel.tsx @@ -69,6 +69,11 @@ const riskTones = { critical: "danger" } satisfies Record; +const externalWorkOrderStatusLabels = { + draft: "待派发", open: "已分配", in_progress: "执行中", + awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" +} as const; + type IncidentOperation = { target: string; elementType: string; @@ -109,6 +114,9 @@ type ConditionDetailPanelProps = { onContinueConversation: () => void; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; + onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise; onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; onTaskRefresh?: () => void; @@ -122,18 +130,25 @@ export function StatusPill({ className?: string; }) { const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint"; + const externalStatus = condition.kind === "work_order" + ? condition.externalStatus + : condition.operationalTask?.task_type === "external_work_order" + ? condition.operationalTask.work_order_snapshot?.status + : undefined; + const badgeStatus = externalStatus === "awaiting_reply" || externalStatus === "cancelled" + ? externalStatus : condition.status; return ( - {isAgentEvent ? "事件调查" : statusLabels[condition.status]} + {isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]} ); } @@ -145,6 +160,9 @@ export function ConditionDetailPanel({ onContinueConversation, onTaskDecision, onTaskExecute, + onTaskDraftUpdate, + onTaskConfirmDispatch, + onTaskAccessDecision, onTaskReconcile, onLoadTaskEvidenceSnapshot, onTaskRefresh @@ -157,7 +175,6 @@ export function ConditionDetailPanel({ condition.kind === "work_order" ? ( ) : ( void; onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"]; onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"]; + onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"]; + onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"]; + onTaskAccessDecision?: ConditionDetailPanelProps["onTaskAccessDecision"]; onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"]; onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"]; onTaskRefresh?: () => void; @@ -239,6 +265,7 @@ function ConditionRecordDetailPanel({ icon={header.icon} iconClassName={header.iconClassName} iconSpin={header.iconSpin} + hideDetail={condition.operationalTask?.task_type === "external_work_order"} /> @@ -250,16 +277,17 @@ function ConditionRecordDetailPanel({ exit={{ opacity: 0, y: -8, scale: 0.996 }} transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }} > - {condition.operationalTask ? ( + {condition.operationalTask?.task_type === "external_work_order" ? ( + externalWorkOrder ? ( + + ) : ( + + +

尚未同步到工单详情,请刷新工况任务后查看。

+
+ ) + ) : condition.operationalTask ? ( <> - {externalWorkOrder ? : null} - {condition.operationalTask.task_type === "external_work_order" - && !condition.operationalTask.work_order_snapshot ? ( - - -

后端尚未提供工单快照,不根据任务状态推测现场进展。

-
- ) : null} { + if (!onTaskDraftUpdate) return Promise.reject(new Error("当前未配置工单草稿编辑接口。")); + return onTaskDraftUpdate(condition.operationalTask!.task_id, actionId, version, payload); + }} + onConfirmDispatch={(actionId, version) => { + if (!onTaskConfirmDispatch) return Promise.reject(new Error("当前未配置工单下发接口。")); + return onTaskConfirmDispatch(condition.operationalTask!.task_id, actionId, version); + }} + onAccessDecision={(requestId, version, decision, scopes) => { + if (!onTaskAccessDecision) return Promise.reject(new Error("当前未配置数据授权接口。")); + return onTaskAccessDecision(condition.operationalTask!.task_id, requestId, version, decision, scopes); + }} onReconcile={(actionId, version) => { if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。")); return onTaskReconcile(condition.operationalTask!.task_id, actionId, version); @@ -439,13 +479,7 @@ function SourceBoundaryNotice({ ); } -function WorkOrderDetailPanel({ - condition, - dataSource -}: { - condition: ScheduledWorkOrderItem; - dataSource: WorkbenchWorkOrderDataSource; -}) { +function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem }) { return ( @@ -453,23 +487,10 @@ function WorkOrderDetailPanel({ condition={condition} icon={ClipboardList} iconClassName="bg-blue-50 text-blue-700" + hideDetail /> - - - + @@ -562,13 +583,15 @@ type DetailHeaderProps = { icon: typeof FileText; iconClassName?: string; iconSpin?: boolean; + hideDetail?: boolean; }; function DetailHeader({ condition, icon: Icon, iconClassName, - iconSpin = false + iconSpin = false, + hideDetail = false }: DetailHeaderProps) { const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint"; const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks"; @@ -593,7 +616,7 @@ function DetailHeader({ ) : null}

{condition.title}

- {condition.detail ?

{condition.detail}

: null} + {condition.detail && !hideDetail ?

{condition.detail}

: null} @@ -1271,51 +1294,154 @@ function TaskExecutionStepRow({ ); } -export function WorkOrderActivityPanel({ condition }: { condition: ScheduledWorkOrderItem }) { +export function WorkOrderReportPanel({ condition }: { condition: ScheduledWorkOrderItem }) { + const latestFeedback = [...condition.activity] + .filter((entry) => entry.kind === "feedback") + .sort((a, b) => b.sequence - a.sequence)[0]; + const progress = getWorkOrderProgress(condition); + const steps = getWorkOrderExecutionSteps(condition, latestFeedback !== undefined); + return ( - -
- - {statusLabels[condition.status]} -
-
- - - - -
- {condition.location ?

位置:{condition.location}

: null} - {condition.feedbackRequirements.length ? ( -
-

回执要求

-
    - {condition.feedbackRequirements.map((item, index) =>
  • {item}
  • )} -
-
- ) : null} -
-

活动记录

- {condition.activity.length ? ( -
    - {[...condition.activity].sort((a, b) => b.sequence - a.sequence).map((entry) => ( -
  1. -
    {entry.title}
    -

    {entry.detail}

    -

    {entry.actor}

    -
  2. - ))} +
    + +
    +
    +
    +

    工单报告

    + +
    + {progress.label} +
    +

    {condition.summary}

    +

    工单编号:{condition.code}

    +
    +

    派单信息

    +
    + + + + + + +
    +
    +
    + {progress.cancelled ? ( +

    工单已取消,后续办理步骤不适用。

    + ) : ( +
      + {steps.map((step, index) => )}
    - ) :

    暂无活动记录;不根据工单状态推测处置过程。

    } -
-
+ )} + + +
+ + {condition.feedbackRequirements.length ? ( +
    + {condition.feedbackRequirements.map((item, index) => ( +
  1. + {index + 1} + {item} +
  2. + ))} +
+ ) :

未填写回执要求

} +
+

现场反馈

+ {latestFeedback ? ( +
+

{latestFeedback.detail}

+

{latestFeedback.actor} · {formatWorkOrderDateTime(latestFeedback.occurredAt)}

+
+ ) :

暂无现场反馈

} +
+
+
+ + +

{progress.description}

+ {condition.recommendation ?

{condition.recommendation}

: null} +
+ ); } +function WorkOrderField({ label, value }: { label: string; value: string }) { + return
{label}
{value}
; +} + +function getWorkOrderExecutionSteps(condition: ScheduledWorkOrderItem, hasFeedback: boolean): ExecutionStep[] { + const status = getWorkOrderSourceStatus(condition); + return [ + { + id: "dispatch", title: "派发", status: status === "open" ? "done" : "pending", + statusLabel: status === "draft" ? "待派发" : status === "open" ? "已分配" : "未单独记录", + description: status === "open" ? "外围工单已分配,接单确认与实际派发时间未单独记录。" : "派发人与派发时间以来源工单的明确记录为准。", + meta: [{ label: "责任人", value: condition.dispatcher === "外围系统同步" ? "未记录" : condition.dispatcher }, + { label: "时间", value: "未记录" }] + }, + { + id: "execution", title: "执行中", status: status === "in_progress" ? "current" : "pending", + statusLabel: status === "in_progress" ? "执行中" : status === "draft" || status === "open" ? "待执行" : "未单独记录", + description: hasFeedback ? "已收到现场反馈,具体内容见下方。" : "尚无现场处理回填。", + meta: [{ label: "责任人", value: condition.assignee ?? "待分配" }, + { label: condition.durationMinutes ? "计划窗口" : "预约", value: condition.durationMinutes + ? formatTimeRange(condition.scheduledAt, condition.durationMinutes) + : formatWorkOrderScheduledAt(condition) }] + }, + { + id: "reply", title: "待复令", status: status === "awaiting_reply" ? "confirmation" : "pending", + statusLabel: status === "awaiting_reply" ? "待复令" : status === "completed" ? "未单独记录" : "待处理", + description: condition.feedbackRequirements.length ? "按下方要求提交处置结果,由调度确认。" : "等待现场回执与调度确认。", + meta: [{ label: "责任人", value: condition.assignee ?? "待分配" }, + { label: "时间", value: "未记录" }] + } + ]; +} + +function getWorkOrderProgress(condition: ScheduledWorkOrderItem): { + label: string; + cancelled: boolean; + description: string; +} { + const status = getWorkOrderSourceStatus(condition); + switch (status) { + case "draft": return { label: "待派发", cancelled: false, description: "当前仍为草稿,尚未进入已分配状态。" }; + case "open": return { label: "已分配", cancelled: false, description: "工单已分配,暂无独立的接单确认记录。" }; + case "in_progress": return { label: "执行中", cancelled: false, description: "当前处于现场执行状态。" }; + case "awaiting_reply": return { label: "待复令", cancelled: false, description: "当前等待现场回填和调度确认。" }; + case "completed": return { label: "已完成", cancelled: false, description: "工单状态为已完成;具体处理结果以现场回填为准。" }; + case "cancelled": return { label: "已取消", cancelled: true, description: "外围工单状态为已取消。" }; + } +} + +function getWorkOrderSourceStatus(condition: ScheduledWorkOrderItem): NonNullable { + return condition.externalStatus ?? ({ + pending: "draft", running: "in_progress", awaiting_reply: "awaiting_reply", + completed: "completed", cancelled: "cancelled" + } as const)[condition.status]; +} + +function formatWorkOrderDateTime(value: string) { + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? value : new Intl.DateTimeFormat("zh-CN", { + year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false + }).format(timestamp); +} + +function formatWorkOrderScheduledAt(condition: ScheduledWorkOrderItem) { + return condition.workOrderScheduledAt === null + ? "未安排" : formatWorkOrderDateTime(condition.workOrderScheduledAt ?? condition.scheduledAt); +} + function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem { return { id: snapshot.work_order_id, kind: "work_order", code: snapshot.scenario_code ?? snapshot.work_order_id, + externalStatus: snapshot.status, + workOrderScheduledAt: snapshot.scheduled_at, scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at, title: snapshot.title, summary: snapshot.summary, diff --git a/src/features/workbench/components/scheduled-condition-feed.tsx b/src/features/workbench/components/scheduled-condition-feed.tsx index ea3416b..015ad15 100644 --- a/src/features/workbench/components/scheduled-condition-feed.tsx +++ b/src/features/workbench/components/scheduled-condition-feed.tsx @@ -1,6 +1,5 @@ import { CalendarClock, - CalendarPlus, ChevronDown, ChevronRight, ClipboardList, @@ -30,11 +29,6 @@ import { DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Textarea } from "@/shared/ui/textarea"; -import { DateTimeField } from "@/shared/ui/date-time-field"; -import { FieldSelect } from "@/shared/ui/field-select"; import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel"; import { WORKBENCH_TASK_CATEGORIES, @@ -69,14 +63,6 @@ export type ScheduledConditionFeedPresentation = | "desktop-floating" | "mobile-sheet"; -export type ScheduleCreateRequest = { - scheduleKey: string; - title: string; - summary: string; - intervalSeconds: number; - nextRunAt: string; -}; - type ScheduledConditionFeedProps = { presentation?: ScheduledConditionFeedPresentation; conditions?: ScheduledConditionItem[]; @@ -99,11 +85,13 @@ type ScheduledConditionFeedProps = { onOpenTaskCollaboration?: (taskId: string) => Promise; onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise; onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record) => Promise; + onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise; + onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise; onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise; onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise; onTaskRefresh?: () => void; onRefresh?: () => void | Promise; - onCreateSchedule?: (input: ScheduleCreateRequest) => Promise; }; type ScheduledConditionFeedFocusRequest = { @@ -133,11 +121,13 @@ export function ScheduledConditionFeed({ onOpenTaskCollaboration, onTaskDecision, onTaskExecute, + onTaskDraftUpdate, + onTaskConfirmDispatch, + onTaskAccessDecision, onTaskReconcile, onLoadTaskEvidenceSnapshot, onTaskRefresh, - onRefresh, - onCreateSchedule + onRefresh }: ScheduledConditionFeedProps) { const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false); const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState< @@ -147,15 +137,6 @@ export function ScheduledConditionFeed({ const [statusFilter, setStatusFilter] = useState(CONDITION_FILTER_ALL); const [taskCategory, setTaskCategory] = useState("scheduled"); - const [scheduleOpen, setScheduleOpen] = useState(false); - const [scheduleTitle, setScheduleTitle] = useState(""); - const [scheduleKey, setScheduleKey] = useState("scada-diagnosis"); - const [scheduleSummary, setScheduleSummary] = useState(""); - const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15"); - const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15"); - const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000))); - const [scheduleSubmitting, setScheduleSubmitting] = useState(false); - const [scheduleError, setScheduleError] = useState(null); const [refreshing, setRefreshing] = useState(false); const refreshingRef = useRef(false); const mobileSheet = presentation === "mobile-sheet"; @@ -300,7 +281,6 @@ export function ScheduledConditionFeed({ function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) { setTaskCategory(nextCategory); - if (nextCategory !== "scheduled") setScheduleOpen(false); setTaskFilter(CONDITION_FILTER_ALL); setStatusFilter(CONDITION_FILTER_ALL); updateSelectedConditionId( @@ -308,34 +288,6 @@ export function ScheduledConditionFeed({ ); } - function handleToggleSchedule() { - if (!scheduleOpen) { - setTaskCategory("scheduled"); - updateExpanded(true); - } - setScheduleOpen((current) => !current); - } - - async function handleCreateSchedule() { - const title = scheduleTitle.trim(); - const summary = scheduleSummary.trim(); - const key = scheduleKey.trim(); - const intervalMinutes = Number(scheduleIntervalMinutes); - const nextRunAt = new Date(scheduleNextRunAt); - if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; } - if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; } - if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; } - if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; } - setScheduleSubmitting(true); setScheduleError(null); - try { - await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() }); - setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary(""); - showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" }); - } catch (error) { - setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。"); - } finally { setScheduleSubmitting(false); } - } - async function handleRefresh() { if (!onRefresh || refreshingRef.current) return; refreshingRef.current = true; @@ -474,17 +426,6 @@ export function ScheduledConditionFeed({

工况任务

{headerSummary}

- {onCreateSchedule ? ( - - ) : null} {onRefresh ? (