feat(workbench): refine dynamic task collaboration

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent b64d2b78bb
commit 87cc7785da
25 changed files with 1216 additions and 470 deletions
@@ -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({
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
+6 -3
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const schema = z.object({
schema_version: z.literal("execution-plan@1"),
schema_version: z.enum(["execution-plan@1", "execution-plan@2"]),
session_id: z.string().min(1),
revision: z.number().int().positive(),
updated_at: z.string().datetime(),
@@ -12,9 +12,12 @@ const schema = z.object({
id, title: z.string().min(1).max(120),
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
owner: z.enum(["agent", "human"]),
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12)
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12),
kind: z.enum(["activity", "decision", "parallel_split", "parallel_join", "gate"]).optional(),
selected_edge_id: id.nullable().optional(), gate_ref: z.string().uuid().nullable().optional(),
revisits_node_id: id.nullable().optional()
})).min(1).max(40),
edges: z.array(z.object({ from: id, to: id, label: z.string().max(120) })).max(80)
edges: z.array(z.object({ id: id.optional(), from: id, to: id, label: z.string().max(120) })).max(80)
}).refine((plan) => {
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);
@@ -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");
});
});
@@ -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<typeof taskSummarySchema>;
export type OperationalTask = z.infer<typeof operationalTaskSchema>;
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
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 OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
@@ -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<string, unknown>;
}, 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",
@@ -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(<ExecutionPlanSteps plan={plan} />);
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(<ExecutionPlanSteps plan={plan} />);
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<ComponentProps<type
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
...overrides
};
render(<AgentExecutionPlanPanel {...props} />);
return props;
const { rerender } = render(<AgentExecutionPlanPanel {...props} />);
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(<ExecutionPlanSteps plan={plan} />);
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(<ExecutionPlanSteps plan={plan} />);
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(<AgentExecutionPlanPanel {...props} task={{ ...initialTask, actions: [{ ...initialTask.actions[0], version: 4, payload: { ...initialTask.actions[0].payload, title: "人工修订草稿" } }] }} />);
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();
@@ -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<unknown>;
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch?: (actionId: string, expectedVersion: number) => Promise<unknown>;
onAccessDecision?: (requestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
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) => (
<HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} onExecute={onExecute} onReconcile={onReconcile} onRefresh={onRefresh} />
task.actions.filter((action) => action.node_id === node.id).map((action) => (
<HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />
))
])) : {};
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({
)}
</section>
{task.access_requests.length > 0 ? <section aria-label="数据访问审核" className="space-y-2">
{task.access_requests.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={plan?.nodes.find((node) => node.id === request.node_id)?.title}
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>
@@ -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<string, ReactNode> }) {
return <div aria-label="调查执行步骤" className="px-3 py-3"><ExecutionFlow plan={plan} nodeActions={nodeActions} depth={0} /></div>;
return <div aria-label="调查执行步骤" className="px-3 py-3">{
plan.nodes.some((node) => node.kind)
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} />
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
}</div>;
}
function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; depth: number }) {
const layout = createBranchLayout(plan);
if (!layout || depth > 8) return <ExactDependencyList plan={plan} nodeActions={nodeActions} />;
return (
<div className="grid gap-2">
<ExecutionChain nodes={layout.prefix} plan={plan} nodeActions={nodeActions} />
{layout.branches.length > 0 ? (
<section aria-label={`并行分枝 ${layout.branches.length} 项`} data-execution-layout="branch" className="min-w-0 py-1">
<div className="mb-2 flex items-center gap-2">
<StatusIcon tone="info" shape="circle" size="md"><GitFork size={14} /></StatusIcon>
<span className="text-xs font-semibold text-slate-800">并行处理</span>
<StatusBadge tone="info">{layout.branches.length} 条独立分枝</StatusBadge>
</div>
<div className="grid snap-x snap-mandatory auto-cols-[86%] grid-flow-col gap-2 overflow-x-auto overscroll-x-contain pb-1 sm:snap-none sm:grid-flow-row sm:grid-cols-2 sm:overflow-visible">
{layout.branches.map((branch, index) => {
const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id);
return (
<section key={branch.map((node) => node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5">
<p className="mb-2 text-[11px] font-semibold text-slate-500">分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}</p>
<ExecutionChain nodes={branch} plan={plan} nodeActions={nodeActions} compact />
</section>
);
})}
</div>
</section>
) : null}
{layout.suffix.length > 0 ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-[11px] text-slate-500"><span className="h-px flex-1 bg-slate-200" /><span>分枝汇合</span><span className="h-px flex-1 bg-slate-200" /></div>
<ExecutionFlow plan={subPlan(plan, layout.suffix.map((node) => node.id))} nodeActions={nodeActions} depth={depth + 1} />
</div>
) : null}
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
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);
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>;
}
function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; compact?: boolean }) {
return <div className="grid gap-0">{nodes.map((node, index) => {
const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined;
return <div key={node.id}>{index > 0 ? <div className="flex min-h-6 items-center gap-2 px-3 text-[10px] text-slate-500"><span className="h-6 w-px bg-slate-300" />{edge?.condition ? <span>{edge.condition}</span> : null}</div> : null}<ExecutionNodeCard node={node} action={nodeActions[node.id]} compact={compact} /></div>;
})}</div>;
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: {
nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[];
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
}) {
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 <div className="space-y-2">
<ExecutionNodeCard node={node} action={nodeActions[node.id]} />
{node.kind === "decision" ? <div className="rounded-lg bg-blue-50/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-slate-500">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
</div> : null}
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-blue-100 bg-blue-50/40 p-2">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-blue-800"><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-slate-200/70 bg-white p-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} />
</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}
</div>;
}
function findSemanticJoin(outgoing: ExecutionEdge[], activeEdges: ExecutionEdge[], nodeById: Map<string, ExecutionNode>, 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<string, ReactNode> }) {
return <ol aria-label="调查执行步骤" className="grid gap-2">{topologicalNodes(plan).map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} /></li>)}</ol>;
}
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 <div><p className="mb-2 text-[11px] leading-5 text-slate-500">历史流程按原始依赖展示;未标注路线或并行类型。</p><ol className="grid gap-2">{topologicalNodes(plan).map((node) => {
const prerequisites = plan.edges.filter((edge) => edge.to === node.id);
return <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact />{prerequisites.length > 0 ? <p className="px-2 pt-1 text-[11px] leading-5 text-slate-500">前置:{prerequisites.map((edge) => `${titleById.get(edge.from) ?? edge.from}${edge.condition || edge.label ? `(${edge.condition ?? edge.label})` : ""}`).join("、")}</p> : null}</li>;
})}</ol></div>;
}
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
@@ -280,6 +322,7 @@ function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionN
<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-blue-700">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
{action ? <div className="mt-2">{action}</div> : null}
</div>
@@ -356,25 +399,70 @@ function EvidenceMeta({ label, value, mono = false }: { label: string; value: st
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-slate-500">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
}
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<string | null>(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 <article className="surface-reading rounded-xl border border-amber-200/80 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">数据访问申请</h3><StatusBadge tone={request.status === "approved" ? "success" : request.status === "rejected" ? "danger" : "warning"}>{request.status === "approved" ? "已批准" : request.status === "rejected" ? "已拒绝" : "待审核"}</StatusBadge></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{request.reason}</p>
<p className="mt-1 text-[11px] text-slate-500">关联步骤 {nodeTitle ?? request.node_id} · 申请于 {formatDateTime(request.requested_at)}</p>
<div className="mt-2 grid gap-1">{request.scopes.map((scope) => <label key={scope} className="flex min-h-10 items-center gap-2 text-xs text-slate-700"><input type="checkbox" checked={selected.includes(scope)} disabled={request.status !== "pending" || !canDecide || busy} onChange={(event) => setSelected((current) => event.target.checked ? [...current, scope] : current.filter((item) => item !== scope))} />{dataScopeLabel(scope)}</label>)}</div>
{request.status !== "pending" ? <p className="mt-1 text-xs text-slate-600">{request.status === "approved" ? `已授权:${request.approved_scopes.map(dataScopeLabel).join("、")}` : "未授予数据访问权限"}</p> : null}
{request.status === "pending" && canDecide && onDecision ? <div className="mt-2 flex justify-end gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void decide("rejected")}>拒绝</Button><Button size="sm" disabled={busy || selected.length === 0} onClick={() => void decide("approved")}>批准所选范围</Button></div> : null}
{request.status === "pending" && !canDecide ? <p className="mt-2 text-xs text-slate-500">等待具有该数据源读取权限的项目成员审核。</p> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
</article>;
}
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<string | null>(null);
const [stale, setStale] = useState(false);
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 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
<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>
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-slate-500">原因:</span>{action.reason}</p>
<ActionPayloadSummary payload={action.payload} />
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-slate-200 bg-slate-50 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="min-h-10 rounded-lg border border-slate-200 bg-white 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="min-h-10 rounded-lg border border-slate-200 bg-white 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>
<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> : null}
{action.evidence_refs.length > 0 ? (
<div className="mt-2 rounded-lg bg-blue-50/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
<p className="font-semibold text-blue-700">确认依据</p>
@@ -409,6 +506,7 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
</div>
) : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-slate-50 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-slate-200/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-slate-200/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-slate-500">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
{error ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{error} 当前状态未按成功处理。</p> : null}
@@ -416,9 +514,9 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-slate-50 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><Button size="sm" disabled={submitting !== null} onClick={() => void run("approve")}>{submitting === "approve" ? <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(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", "failed", "unsupported"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div>
</div>
</div>
@@ -485,41 +583,6 @@ function externalStatusLabel(value: string) {
return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record<string, string>)[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<string>();
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<string, ExecutionEdge[]>) {
const seen = new Set<string>();
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<string, string>)[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]; }
@@ -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(<WorkOrderActivityPanel condition={workOrder} />);
expect(screen.getByText("工单创建")).toBeVisible();
render(<WorkOrderReportPanel condition={workOrder} />);
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();
});
@@ -69,6 +69,11 @@ const riskTones = {
critical: "danger"
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
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<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
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 (
<StatusBadge
tone={
isAgentEvent ? "neutral" : getStatusTone(condition.status)
isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
}
activity={
isAgentEvent ? "static" : getStatusActivity(condition.status)
isAgentEvent ? "static" : getStatusActivity(badgeStatus)
}
className={className}
>
{isAgentEvent ? "事件调查" : statusLabels[condition.status]}
{isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -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" ? (
<WorkOrderDetailPanel
condition={condition}
dataSource={condition.dataSource ?? workOrderDataSource}
/>
) : (
<ConditionRecordDetailPanel
@@ -167,6 +184,9 @@ export function ConditionDetailPanel({
onContinueConversation={onContinueConversation}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
onTaskAccessDecision={onTaskAccessDecision}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
@@ -196,6 +216,9 @@ function ConditionRecordDetailPanel({
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
@@ -206,6 +229,9 @@ function ConditionRecordDetailPanel({
onContinueConversation: () => 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"}
/>
<DetailDecisionBody>
<AnimatePresence initial={false} mode="wait">
@@ -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 ? (
<WorkOrderReportPanel condition={externalWorkOrder} />
) : (
<DetailSectionCard>
<SectionTitle icon={ClipboardList} title="工单报告" />
<p className="mt-2 text-xs leading-5 text-slate-500">尚未同步到工单详情,请刷新工况任务后查看。</p>
</DetailSectionCard>
)
) : condition.operationalTask ? (
<>
{externalWorkOrder ? <WorkOrderActivityPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot ? (
<DetailSectionCard>
<SectionTitle icon={ListChecks} title="预约过程" />
<p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单快照,不根据任务状态推测现场进展。</p>
</DetailSectionCard>
) : null}
<AgentExecutionPlanPanel
task={condition.operationalTask}
onContinue={onContinueConversation}
@@ -271,6 +299,18 @@ function ConditionRecordDetailPanel({
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
return onTaskExecute(condition.operationalTask!.task_id, actionId, version);
}}
onDraftUpdate={(actionId, version, payload) => {
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 (
<DetailScroll>
<DetailShell>
@@ -453,23 +487,10 @@ function WorkOrderDetailPanel({
condition={condition}
icon={ClipboardList}
iconClassName="bg-blue-50 text-blue-700"
hideDetail
/>
<DetailDecisionBody>
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend"
? "工单状态、负责人、回执要求与活动记录均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
/>
<WorkOrderActivityPanel condition={condition} />
<DecisionActionStrip
icon={CalendarClock}
title="工单跟踪"
value={condition.recommendation ?? "按工单活动记录跟踪现场处置。"}
actionLabel={condition.status === "awaiting_reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
/>
<WorkOrderReportPanel condition={condition} />
</DetailDecisionBody>
</DetailShell>
</DetailScroll>
@@ -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}
</div>
<h3 className="mt-1 text-base font-semibold leading-5 text-slate-950">{condition.title}</h3>
{condition.detail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
{condition.detail && !hideDetail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
</div>
</div>
@@ -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 (
<DetailSectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={ClipboardList} title="预约工单" />
<StatusBadge tone={getStatusTone(condition.status)}>{statusLabels[condition.status]}</StatusBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
<ReportMetaTile label="来源" value={condition.source} />
<ReportMetaTile label="负责人" value={condition.assignee ?? "待分配"} />
<ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<ReportMetaTile label="预约时间" value={formatTime(condition.scheduledAt)} />
</div>
{condition.location ? <p className="mt-3 text-xs text-slate-600">位置:{condition.location}</p> : null}
{condition.feedbackRequirements.length ? (
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">回执要求</h4>
<ul className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => <li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700"><CheckCircle2 className="mt-0.5 shrink-0 text-blue-600" size={14} />{item}</li>)}
</ul>
</section>
) : null}
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">活动记录</h4>
{condition.activity.length ? (
<ol className="mt-2 border-l border-blue-200 pl-4">
{[...condition.activity].sort((a, b) => b.sequence - a.sequence).map((entry) => (
<li key={entry.sequence} className="relative pb-4 last:pb-0 before:absolute before:-left-[21px] before:top-1 before:h-2.5 before:w-2.5 before:rounded-full before:bg-blue-500">
<div className="flex flex-wrap items-baseline justify-between gap-1"><span className="text-xs font-semibold text-slate-800">{entry.title}</span><time className="text-[10px] text-slate-500">{formatTime(entry.occurredAt)}</time></div>
<p className="mt-1 text-xs leading-5 text-slate-600">{entry.detail}</p>
<p className="mt-1 text-[10px] text-slate-400">{entry.actor}</p>
</li>
))}
<div className="space-y-2.5">
<DetailSectionCard className="overflow-hidden p-0">
<div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-[10px] font-semibold text-slate-400">工单报告</p>
<SectionTitle icon={ClipboardList} title="任务执行" />
</div>
<StatusBadge tone={getStatusTone(condition.status)}>{progress.label}</StatusBadge>
</div>
<p className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{condition.summary}</p>
<p className="mt-1 break-all text-[11px] text-slate-500">工单编号:{condition.code}</p>
<section aria-label="派单信息" className="mt-3">
<h4 className="mb-1.5 text-[11px] font-semibold text-slate-500">派单信息</h4>
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<WorkOrderField label="来源" value={condition.source} />
<WorkOrderField label="位置" value={condition.location ?? "未填写"} />
<WorkOrderField label="派发人" value={condition.dispatcher === "外围系统同步" ? "未记录" : condition.dispatcher} />
<WorkOrderField label="执行班组" value={condition.assignee ?? "待分配"} />
<WorkOrderField label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<WorkOrderField label="预约时间" value={formatWorkOrderScheduledAt(condition)} />
</dl>
</section>
</div>
{progress.cancelled ? (
<p className="px-3 py-3 text-xs text-slate-600">工单已取消,后续办理步骤不适用。</p>
) : (
<ol aria-label="工单进度" className="grid gap-2 px-3 py-3">
{steps.map((step, index) => <TaskExecutionStepRow key={step.id} step={step} isLast={index === steps.length - 1} />)}
</ol>
) : <p className="mt-2 text-xs text-slate-500">暂无活动记录;不根据工单状态推测处置过程。</p>}
</section>
</DetailSectionCard>
)}
</DetailSectionCard>
<DetailSectionCard className="px-3 py-3">
<section aria-label="回执信息">
<SectionTitle icon={ListChecks} title="复令要求" />
{condition.feedbackRequirements.length ? (
<ol className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => (
<li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700">
<span className="grid size-5 shrink-0 place-items-center rounded-full border border-blue-100 bg-white text-[11px] font-semibold text-blue-700">{index + 1}</span>
<span className="min-w-0 break-words">{item}</span>
</li>
))}
</ol>
) : <p className="mt-2 text-xs text-slate-500">未填写回执要求</p>}
<div className="mt-3 border-t border-slate-200/70 pt-3">
<p className="text-[11px] font-semibold text-slate-500">现场反馈</p>
{latestFeedback ? (
<div className="mt-1 rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-700">
<p className="whitespace-pre-wrap break-words">{latestFeedback.detail}</p>
<p className="mt-1 text-[11px] text-slate-500">{latestFeedback.actor} · {formatWorkOrderDateTime(latestFeedback.occurredAt)}</p>
</div>
) : <p className="mt-1 text-xs text-slate-500">暂无现场反馈</p>}
</div>
</section>
</DetailSectionCard>
<DetailSectionCard className="px-3 py-3">
<SectionTitle icon={CalendarClock} title={`当前状态:${progress.label}`} />
<p className="mt-1.5 text-xs leading-5 text-slate-700">{progress.description}</p>
{condition.recommendation ? <p className="mt-1 text-xs leading-5 text-slate-700">{condition.recommendation}</p> : null}
</DetailSectionCard>
</div>
);
}
function WorkOrderField({ label, value }: { label: string; value: string }) {
return <div className={cn(REPORT_META_TILE_CLASS_NAME, "min-w-0")}><dt className="text-[11px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-xs font-medium leading-5 text-slate-800">{value}</dd></div>;
}
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<ScheduledWorkOrderItem["externalStatus"]> {
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,
@@ -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<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>;
onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onRefresh?: () => void | Promise<void>;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
};
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<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("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<string | null>(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({
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
{onCreateSchedule ? (
<button
type="button"
aria-label="配置定时流程"
aria-expanded={scheduleOpen}
onClick={handleToggleSchedule}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<CalendarPlus size={17} aria-hidden="true" />
</button>
) : null}
{onRefresh ? (
<button
type="button"
@@ -542,26 +483,6 @@ export function ScheduledConditionFeed({
/>
) : null}
{scheduleOpen ? (
<section aria-label="配置定时流程" className={cn("mt-2 p-3", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<label className="block text-xs font-medium text-slate-600">定时流程名称<Input className="mt-1 h-9" placeholder="例如夜间低压巡检" value={scheduleTitle} onChange={(event) => setScheduleTitle(event.target.value)} /></label>
<div className="mt-2"><FieldSelect label="执行流程" value={scheduleKey} onValueChange={setScheduleKey} options={[{ value: "scada-diagnosis", label: "SCADA 数据诊断" }, { value: "smart-dispatch", label: "管网智能调度" }, { value: "network-simulation", label: "管网定时模拟" }, { value: "pump-energy", label: "泵站能耗检查" }]} /></div>
<label className="mt-2 block text-xs font-medium text-slate-600">任务说明<Textarea className="mt-1 min-h-16" placeholder="说明本流程核查什么以及预期输出" value={scheduleSummary} onChange={(event) => setScheduleSummary(event.target.value)} /></label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<FieldSelect label="执行周期" value={scheduleIntervalPreset} onValueChange={(value) => { setScheduleIntervalPreset(value); if (value !== "custom") setScheduleIntervalMinutes(value); }} options={[{ value: "5", label: "每 5 分钟" }, { value: "15", label: "每 15 分钟" }, { value: "30", label: "每 30 分钟" }, { value: "60", label: "每 60 分钟" }, { value: "custom", label: "自定义周期" }]} />
{scheduleIntervalPreset === "custom" ? <label className="mt-2 block text-xs font-medium text-slate-600">自定义分钟数<Input className="mt-1 h-9" inputMode="numeric" value={scheduleIntervalMinutes} onChange={(event) => setScheduleIntervalMinutes(event.target.value.replace(/\D/g, ""))} /></label> : null}
</div>
<DateTimeField label="下次执行时间" value={scheduleNextRunAt} onChange={setScheduleNextRunAt} />
</div>
{scheduleError ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{scheduleError}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" disabled={scheduleSubmitting} onClick={() => setScheduleOpen(false)}>取消</Button>
<Button type="button" size="sm" disabled={scheduleSubmitting} onClick={() => void handleCreateSchedule()}>{scheduleSubmitting ? "创建中" : "创建定时流程"}</Button>
</div>
</section>
) : null}
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
@@ -595,6 +516,9 @@ export function ScheduledConditionFeed({
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
onTaskAccessDecision={onTaskAccessDecision}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
@@ -690,11 +614,6 @@ function TaskCategorySwitch({
);
}
function formatLocalDateTimeInput(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
@@ -0,0 +1,33 @@
import { ShieldCheck } from "lucide-react";
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
type TaskConversationReviewProps = {
task: OperationalTask;
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
onRefresh: () => void;
};
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
const pendingAccess = 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-blue-200/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>
{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}
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
onRefresh={onRefresh} />)}
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
</section>;
}
@@ -52,6 +52,7 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
latest_report: null,
evidence: [],
actions: [],
access_requests: [],
permissions: {
can_publish_artifacts: false,
can_propose_actions: false,
@@ -59,7 +60,8 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
can_decide_work_orders: false,
can_execute_work_orders: false,
can_decide_commands: false,
can_execute_commands: false
can_execute_commands: false,
can_decide_access: false
},
work_order_snapshot: {
work_order_id: "44444444-4444-4444-8444-444444444444",
@@ -5,6 +5,9 @@ import {
createOperationalRequestId,
createOperationalTask,
decideOperationalTaskAction,
decideOperationalTaskAccess,
updateOperationalWorkOrderDraft,
confirmOperationalWorkOrderDispatch,
executeOperationalTaskAction,
fetchOperationalTask,
fetchOperationalTaskEvidenceSnapshot,
@@ -72,6 +75,7 @@ export function useOperationalTasks(
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const forceRefreshRef = useRef(false);
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
const refresh = useCallback(() => {
forceRefreshRef.current = true;
@@ -84,7 +88,9 @@ export function useOperationalTasks(
const summaries = sync.snapshot?.tasks ?? [];
try {
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
const changed = forceRefreshRef.current
const resetTaskDetails = forceRefreshRef.current
|| detailResetGenerationRef.current !== sync.detailResetGeneration;
const changed = resetTaskDetails
? summaries
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
const details = await Promise.all(changed.map((task) =>
@@ -98,8 +104,11 @@ export function useOperationalTasks(
});
tasksRef.current = nextTasks;
forceRefreshRef.current = false;
detailResetGenerationRef.current = sync.detailResetGeneration;
setTasks(nextTasks);
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
setNotifications((sync.snapshot?.notifications ?? []).filter(
(item) => item.lifecycle_status === "active" && item.status === "unread"
));
setAvailable(true);
activatedRef.current = true;
setError(null);
@@ -118,7 +127,7 @@ export function useOperationalTasks(
return () => {
controller.abort();
};
}, [getAccessToken, refreshGeneration, sync.snapshot]);
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
useEffect(() => {
const events = syncEvents;
@@ -234,6 +243,45 @@ export function useOperationalTasks(
return action;
}, [getAccessToken]);
const updateWorkOrderDraft = useCallback(async (
taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>
) => {
const action = await updateOperationalWorkOrderDraft(taskId, actionId, {
request_id: createOperationalRequestId(), expected_version: expectedVersion, payload
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item, actions: item.actions.map((entry) => entry.action_id === actionId ? action : entry)
} : item));
return action;
}, [getAccessToken]);
const confirmWorkOrderDispatch = useCallback(async (
taskId: string, actionId: string, expectedVersion: number
) => {
const action = await confirmOperationalWorkOrderDispatch(taskId, actionId, {
request_id: createOperationalRequestId(), expected_version: expectedVersion
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item, actions: item.actions.map((entry) => entry.action_id === actionId ? action : entry)
} : item));
return action;
}, [getAccessToken]);
const decideAccess = useCallback(async (
taskId: string, accessRequestId: string, expectedVersion: number,
decision: "approved" | "rejected", approvedScopes: string[]
) => {
const request = await decideOperationalTaskAccess(taskId, accessRequestId, {
request_id: createOperationalRequestId(), expected_version: expectedVersion,
decision, approved_scopes: approvedScopes
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item, access_requests: item.access_requests.map((entry) => entry.access_request_id === accessRequestId ? request : entry)
} : item));
refresh();
return request;
}, [getAccessToken, refresh]);
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
await acknowledgeOperationalTaskNotification(
notificationId,
@@ -261,6 +309,9 @@ export function useOperationalTasks(
decideAction,
executeAction,
reconcileAction,
updateWorkOrderDraft,
confirmWorkOrderDispatch,
decideAccess,
acknowledgeNotification,
loadEvidenceSnapshot
};
@@ -89,6 +89,7 @@ export function useWorkbenchAgent({
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -131,6 +132,7 @@ export function useWorkbenchAgent({
const clearSessionId = useCallback(() => {
sessionIdRef.current = null;
setSessionId(null);
setSessionTaskId(null);
}, []);
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
@@ -549,6 +551,7 @@ export function useWorkbenchAgent({
applySessionId(loadedSession.sessionId);
setSessionPurpose(loadedSession.purpose);
setSessionTaskId(loadedSession.taskId ?? null);
setSessionTitle(loadedSession.title);
setStatusLabel("Agent 历史会话已打开");
setPermissionOverrides({});
@@ -632,6 +635,7 @@ export function useWorkbenchAgent({
clearSessionId();
setSessionTitle("新对话");
setSessionPurpose("ordinary_chat");
setSessionTaskId(null);
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
setPermissionOverrides({});
@@ -917,6 +921,7 @@ export function useWorkbenchAgent({
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
sessionId,
sessionPurpose,
sessionTaskId,
sessionTitle,
statusLabel,
streamRenderState,
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { shouldApplyWorkbenchSnapshot } from "./use-workbench-sync";
describe("workbench snapshot ordering", () => {
it("rejects an older concurrent response even when its cursor is valid", () => {
expect(shouldApplyWorkbenchSnapshot({
requestGeneration: 1,
latestRequestGeneration: 2,
eventCursor: 120,
currentCursor: 100
})).toBe(false);
});
it("rejects a snapshot whose cursor trails already consumed events", () => {
expect(shouldApplyWorkbenchSnapshot({
requestGeneration: 2,
latestRequestGeneration: 2,
eventCursor: 99,
currentCursor: 100
})).toBe(false);
});
it("accepts only the latest response without cursor rollback", () => {
expect(shouldApplyWorkbenchSnapshot({
requestGeneration: 2,
latestRequestGeneration: 2,
eventCursor: 100,
currentCursor: 100
})).toBe(true);
});
});
@@ -19,26 +19,38 @@ export type WorkbenchSyncState = {
events: WorkbenchEvent[];
status: "connecting" | "live" | "reconnecting";
error: string | null;
detailResetGeneration: number;
acknowledgeEventsThrough: (eventSequence: number) => void;
refresh: () => Promise<void>;
};
export function shouldApplyWorkbenchSnapshot(input: {
requestGeneration: number;
latestRequestGeneration: number;
eventCursor: number;
currentCursor: number;
}) {
return input.requestGeneration === input.latestRequestGeneration
&& input.eventCursor >= input.currentCursor;
}
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const [detailResetGeneration, setDetailResetGeneration] = useState(0);
const cursorRef = useRef(0);
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
const runtimeRefreshRef = useRef<number | undefined>(undefined);
const refreshSnapshotRef = useRef<(() => Promise<void>) | null>(null);
const refreshSnapshotRef = useRef<((resetTaskDetails?: boolean) => Promise<void>) | null>(null);
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
}, []);
const refresh = useCallback(async () => {
const refreshSnapshot = refreshSnapshotRef.current;
if (!refreshSnapshot) throw new Error("工作台同步尚未就绪");
await refreshSnapshot();
await refreshSnapshot(true);
}, []);
useEffect(() => {
@@ -47,12 +59,24 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
let reconcileTimer: number | undefined;
let retryMs = 1_000;
let initialized = false;
let snapshotRequestGeneration = 0;
const loadSnapshot = async () => {
const loadSnapshot = async (resetTaskDetails = false) => {
const requestGeneration = ++snapshotRequestGeneration;
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
if (controller.signal.aborted) return;
if (
controller.signal.aborted
|| !shouldApplyWorkbenchSnapshot({
requestGeneration,
latestRequestGeneration: snapshotRequestGeneration,
eventCursor: next.eventCursor,
currentCursor: cursorRef.current
})
) return;
cursorRef.current = next.eventCursor;
if (resetTaskDetails) setEvents([]);
setSnapshot(next);
if (resetTaskDetails) setDetailResetGeneration((current) => current + 1);
setError(null);
initialized = true;
};
@@ -69,7 +93,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
after: cursorRef.current,
getAccessToken,
signal: controller.signal,
onReset: loadSnapshot,
onReset: () => loadSnapshot(true),
onEvents: (nextEvents) => {
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
if (unseen.length === 0) return;
@@ -116,5 +140,13 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
};
}, [getAccessToken]);
return { snapshot, events, status, error, acknowledgeEventsThrough, refresh };
return {
snapshot,
events,
status,
error,
detailResetGeneration,
acknowledgeEventsThrough,
refresh
};
}
+20 -46
View File
@@ -40,10 +40,8 @@ import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
import {
ScheduledConditionFeed,
type ScheduleCreateRequest
} from "./components/scheduled-condition-feed";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { TaskConversationReview } from "./components/task-conversation-review";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
import {
@@ -105,7 +103,6 @@ import type {
DetailFeature,
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledConditionTaskId,
WorkbenchAlert,
WorkbenchUser
} from "./types";
@@ -115,17 +112,6 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
const SCHEDULED_CONDITION_TASK_IDS = new Set<ScheduledConditionTaskId>([
"scada-diagnosis",
"smart-dispatch",
"pump-energy",
"network-simulation"
]);
function isScheduledConditionTaskId(value: string): value is ScheduledConditionTaskId {
return SCHEDULED_CONDITION_TASK_IDS.has(value as ScheduledConditionTaskId);
}
export function MapWorkbenchPage({
user = WORKBENCH_USER,
onLogout,
@@ -164,9 +150,6 @@ export function MapWorkbenchPage({
const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [createdMockScheduledTasks, setCreatedMockScheduledTasks] = useState<
ScheduledConditionRecord[]
>([]);
const workbenchSync = useWorkbenchSync(getAccessToken);
const workbenchSyncReconnecting =
workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null;
@@ -228,7 +211,6 @@ export function MapWorkbenchPage({
getAccessToken
});
const scheduledConditions = useMemo(() => [
...createdMockScheduledTasks,
...runtimeScheduledConditions,
...(operationalTasks.available
? operationalTasks.conditions.filter((condition) =>
@@ -236,30 +218,7 @@ export function MapWorkbenchPage({
|| condition.operationalTask?.task_type === "external_work_order"
)
: [])
], [createdMockScheduledTasks, operationalTasks.available, operationalTasks.conditions, runtimeScheduledConditions]);
const handleCreateMockScheduledTask = useCallback(async (input: ScheduleCreateRequest) => {
const taskId = isScheduledConditionTaskId(input.scheduleKey)
? input.scheduleKey
: "scada-diagnosis";
const condition: ScheduledConditionRecord = {
id: `local-scheduled-task-${crypto.randomUUID()}`,
kind: "condition",
taskId,
dataSource: "mock",
sessionId: `scheduled-condition-${taskId}-${Date.now()}`,
title: input.title,
summary: input.summary,
status: "pending",
riskLevel: "normal",
scheduledAt: input.nextRunAt,
updatedAt: Date.now(),
detail: `按 ${input.intervalSeconds / 60} 分钟周期执行,仅用于当前前端演示。`,
recommendation: "等待模拟执行窗口。",
modelName: "SupplyFlow 前端演示模拟器"
};
setCreatedMockScheduledTasks((current) => [condition, ...current]);
return condition;
}, []);
], [operationalTasks.available, operationalTasks.conditions, runtimeScheduledConditions]);
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
const route = getOperationalNotificationRoute(notification.kind);
@@ -1097,6 +1056,9 @@ export function MapWorkbenchPage({
}
return items;
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
: undefined;
const agentPanelProps: Omit<
ComponentProps<typeof AgentCommandPanel>,
"collapsing" | "onCollapse"
@@ -1116,6 +1078,14 @@ export function MapWorkbenchPage({
selectedModel: agent.selectedModel,
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
onAccessDecision={(requestId, version, decision, scopes) => operationalTasks.decideAccess(collaborationReviewTask.task_id, requestId, version, decision, scopes)}
onActionDecision={(actionId, version, decision, comment) => operationalTasks.decideAction(collaborationReviewTask.task_id, actionId, version, decision, comment)}
onActionExecute={(actionId, version) => operationalTasks.executeAction(collaborationReviewTask.task_id, actionId, version)}
onDraftUpdate={(actionId, version, payload) => operationalTasks.updateWorkOrderDraft(collaborationReviewTask.task_id, actionId, version, payload)}
onConfirmDispatch={(actionId, version) => operationalTasks.confirmWorkOrderDispatch(collaborationReviewTask.task_id, actionId, version)}
onActionReconcile={(actionId, version) => operationalTasks.reconcileAction(collaborationReviewTask.task_id, actionId, version)}
onRefresh={operationalTasks.refresh} /> : undefined,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
onRefreshHistory: agent.refreshSessionHistory,
@@ -1348,11 +1318,13 @@ export function MapWorkbenchPage({
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
onTaskAccessDecision={operationalTasks.decideAccess}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onRefresh={workbenchSync.refresh}
onCreateSchedule={handleCreateMockScheduledTask}
/>
</div>
) : null}
@@ -1496,11 +1468,13 @@ export function MapWorkbenchPage({
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
onTaskAccessDecision={operationalTasks.decideAccess}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onRefresh={workbenchSync.refresh}
onCreateSchedule={handleCreateMockScheduledTask}
/>
)}
</MobileWorkbenchSheet>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
CalendarClock,
ClipboardList,
@@ -16,7 +16,7 @@ import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import { WorkOrderActivityPanel } from "../components/scheduled-condition-detail-panel";
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import type { ScheduledWorkOrderItem } from "../types";
import type {
@@ -123,6 +123,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
const [deleteCandidateId, setDeleteCandidateId] = useState<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const editorTriggerRef = useRef<HTMLButtonElement>(null);
const editingWorkOrderId = editing?.work_order_id;
const clear = () => {
setEditing(null);
@@ -139,12 +143,34 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setProgressNote("");
};
const create = () => {
const closeEditor = () => {
const trigger = editorTriggerRef.current;
const editTriggerId = editingWorkOrderId ? `work-order-edit-${editingWorkOrderId}` : null;
clear();
requestAnimationFrame(() => {
if (editTriggerId) document.getElementById(editTriggerId)?.focus();
else trigger?.focus();
});
};
useEffect(() => {
if (!editorOpen || !titleInputRef.current) return;
titleInputRef.current.focus({ preventScroll: true });
const target = editingWorkOrderId
? titleInputRef.current.closest("article")
: titleInputRef.current;
target?.scrollIntoView({ block: editingWorkOrderId ? "start" : "nearest" });
}, [editorOpen, editingWorkOrderId]);
const create = (trigger: HTMLButtonElement) => {
editorTriggerRef.current = trigger;
clear();
setEditorOpen(true);
};
const edit = (item: PeripheralWorkOrder) => {
const edit = (item: PeripheralWorkOrder, trigger: HTMLButtonElement) => {
editorTriggerRef.current = trigger;
setDeleteCandidateId(null);
setEditing(item);
setEditorOpen(true);
setTitle(item.title);
@@ -193,6 +219,81 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-slate-200 bg-white focus-visible:outline-0! focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]";
const orderEditor = editorOpen ? (
<section
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
event.stopPropagation();
closeEditor();
}
}}
>
<EditorHeading title={editing ? "编辑工单" : "新增预约工单"} onClose={closeEditor} disabled={Boolean(busy)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
if (editing) {
await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision,
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
clear_assignee: !assignee.trim(),
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
clear_scheduled_at: !scheduledAt,
location: location.trim() || null,
clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
});
} else {
await client.createWorkOrder({
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
work_order_type: "general",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
feedback_requirements: requirements
});
}
closeEditor();
})}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单" : "创建预约工单"}
</Button>
<Button size="sm" variant="ghost" onClick={closeEditor} disabled={Boolean(busy)}>取消</Button>
</div>
</section>
) : null;
return (
<div>
@@ -205,7 +306,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
<Sparkles size={13} />投诉与爆管上报
</Button>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={create}>
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={(event) => create(event.currentTarget)}>
<Plus size={13} />新增预约工单
</Button>
</div>
@@ -238,68 +339,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</WorkbenchNotice>
) : null}
{editorOpen ? (
<section aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"} className="mt-3 rounded-xl bg-white p-3 ring-1 ring-inset ring-slate-200">
<EditorHeading title={editing ? "编辑预约工单" : "新增预约工单"} onClose={clear} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">标题<Input className="mt-1 h-9" value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600 sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<FieldSelect label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
<FieldSelect label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
<label className="block text-xs font-medium text-slate-600">负责人<Input className="mt-1 h-9" value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="block text-xs font-medium text-slate-600">位置<Input className="mt-1 h-9" value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<DateTimeField label="预约时间" value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
<label className="block text-xs font-medium text-slate-600">场景编号<Input className="mt-1 h-9" value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
</div>
{editing ? <p className="mt-2 text-[11px] text-slate-500">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
<label className="mt-3 block text-xs font-medium text-slate-600">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-slate-600">现场反馈(可选)<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
if (editing) {
await client.updateWorkOrder(editing.work_order_id, {
expected_revision: editing.revision,
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
clear_assignee: !assignee.trim(),
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
clear_scheduled_at: !scheduledAt,
location: location.trim() || null,
clear_location: !location.trim(),
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
});
} else {
await client.createWorkOrder({
title: title.trim(),
summary: summary.trim(),
status,
priority,
assignee: assignee.trim() || null,
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
work_order_type: "general",
location: location.trim() || null,
scenario_code: scenarioCode.trim() || null,
feedback_requirements: requirements
});
}
clear();
})}>
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
{editing ? "保存工单" : "创建预约工单"}
</Button>
<Button size="sm" variant="ghost" onClick={clear} disabled={Boolean(busy)}>取消</Button>
</div>
</section>
) : null}
{editorOpen && !editing ? orderEditor : null}
<section aria-label="工单列表" className="mt-3">
{orders.length ? (
@@ -311,9 +351,15 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
index={index}
canWrite={canWrite}
busy={busy}
onEdit={() => edit(item)}
onDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
editing={editing?.work_order_id === item.work_order_id}
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
onEdit={(trigger) => edit(item, trigger)}
confirmingDelete={deleteCandidateId === item.work_order_id}
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
onCancelDelete={() => setDeleteCandidateId(null)}
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
await client.deleteWorkOrder(item.work_order_id, item.revision);
setDeleteCandidateId(null);
if (editing?.work_order_id === item.work_order_id) clear();
})}
/>
@@ -328,15 +374,25 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
);
}
function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, confirmingDelete, onRequestDelete, onCancelDelete, onConfirmDelete }: {
item: PeripheralWorkOrder;
index: number;
canWrite: boolean;
busy: string | null;
onEdit: () => void;
onDelete: () => void;
editing: boolean;
editor: ReactNode;
onEdit: (trigger: HTMLButtonElement) => void;
confirmingDelete: boolean;
onRequestDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
const workOrder = toScheduledWorkOrder(item);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const cancelDelete = () => {
onCancelDelete();
deleteButtonRef.current?.focus();
};
return (
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
<div className="flex items-start gap-2">
@@ -346,14 +402,40 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
</div>
<h6 className="mt-1 text-xs font-semibold text-slate-900">{item.title}</h6>
<h6 className="mt-1 text-balance text-xs font-semibold text-slate-900">{item.title}</h6>
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
</div>
<div className="flex shrink-0 gap-0.5">
<Button size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onEdit}><Pencil size={14} /></Button>
<Button size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 size={14} /></Button>
</div>
{!editing ? <div className="flex shrink-0 gap-0.5">
<Button id={`work-order-edit-${item.work_order_id}`} size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={(event) => onEdit(event.currentTarget)}><Pencil size={14} /></Button>
<Button ref={deleteButtonRef} size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} aria-expanded={confirmingDelete} disabled={!canWrite || Boolean(busy) || confirmingDelete} onClick={onRequestDelete}><Trash2 size={14} /></Button>
</div> : null}
</div>
{confirmingDelete ? (
<div
role="group"
aria-label={`确认删除工单 ${item.title}`}
className="mt-3 rounded-lg bg-rose-50 p-3 ring-1 ring-inset ring-rose-200"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
event.stopPropagation();
cancelDelete();
}
}}
>
<p className="break-words text-balance text-xs font-semibold text-rose-900">删除“{item.title}”?</p>
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">外围样板将移除这张工单,历史版本保留。</p>
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">对应的工况任务将在后台同步后移出任务列表。</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="destructive" disabled={Boolean(busy)} onClick={onConfirmDelete}>
{busy === `delete-order-${item.work_order_id}` ? <LoaderCircle className="animate-spin" /> : <Trash2 size={13} />}
确认删除
</Button>
<Button type="button" size="sm" variant="outline" autoFocus disabled={Boolean(busy)} onClick={cancelDelete}>取消</Button>
</div>
</div>
) : null}
{editor}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-slate-200/80 pt-2 text-[10px] text-slate-500">
<span>{priorityLabel(item.priority)}优先级</span>
{item.location ? <span>位置:{item.location}</span> : null}
@@ -362,7 +444,7 @@ function WorkOrderCard({ item, index, canWrite, busy, onEdit, onDelete }: {
<span className="font-mono">r{item.revision}</span>
</div>
<div className="mt-3">
<WorkOrderActivityPanel condition={workOrder} />
<WorkOrderReportPanel condition={workOrder} />
</div>
</article>
);
@@ -373,6 +455,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
id: item.work_order_id,
kind: "work_order",
code: item.scenario_code ?? item.work_order_id,
externalStatus: item.status,
workOrderScheduledAt: item.scheduled_at,
scheduledAt: item.scheduled_at ?? item.updated_at,
title: item.title,
summary: item.summary,
@@ -381,7 +465,7 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
updatedAt: Date.parse(item.updated_at),
detail: item.summary,
source: "外围工单测试",
dispatcher: item.updated_by,
dispatcher: "未记录",
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
location: item.location ?? undefined,
@@ -392,8 +476,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
};
}
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
function EditorHeading({ title, onClose, disabled = false }: { title: string; onClose: () => void; disabled?: boolean }) {
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-slate-800">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
}
const WORK_ORDER_STATUS_OPTIONS = [
+2
View File
@@ -139,6 +139,8 @@ export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledCondition
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
kind: "work_order";
code: string;
externalStatus?: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
workOrderScheduledAt?: string | null;
source: string;
dispatcher: string;
priority: "normal" | "urgent";