feat(workbench): refine dynamic task collaboration
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user