feat(workbench): refine dynamic task collaboration
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
## 7. 使用约束
|
## 7. 使用约束
|
||||||
|
|
||||||
- 必须优先显示当前状态和可执行的下一步。
|
- 必须优先显示当前状态和可执行的下一步。
|
||||||
|
- 调查计划由 Agent 动态发布。默认呈现本次选中的路线和当前待办,未选路线及旧修订按需展开;条件、并行和审核状态只读取明确的结构化字段。
|
||||||
- 必须明确 synthetic、只读、结果未知和未配置状态。
|
- 必须明确 synthetic、只读、结果未知和未配置状态。
|
||||||
- 必须保留完整请求号、对象修订和证据来源的可读入口。
|
- 必须保留完整请求号、对象修订和证据来源的可读入口。
|
||||||
- 不用颜色代替文字状态。
|
- 不用颜色代替文字状态。
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ type AgentCommandPanelProps = {
|
|||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
approvalMode?: AgentApprovalMode;
|
approvalMode?: AgentApprovalMode;
|
||||||
uiResults?: AgentUiResult[];
|
uiResults?: AgentUiResult[];
|
||||||
|
reviewPanel?: ReactNode;
|
||||||
workspaceContext?: AgentWorkspaceContextItem[];
|
workspaceContext?: AgentWorkspaceContextItem[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
@@ -173,6 +174,7 @@ export function AgentCommandPanel({
|
|||||||
selectedModel = "",
|
selectedModel = "",
|
||||||
approvalMode = "request",
|
approvalMode = "request",
|
||||||
uiResults = [],
|
uiResults = [],
|
||||||
|
reviewPanel,
|
||||||
workspaceContext = [],
|
workspaceContext = [],
|
||||||
prompt,
|
prompt,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
@@ -585,6 +587,7 @@ export function AgentCommandPanel({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
|
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
|
||||||
|
{reviewPanel}
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
<AgentConversationScrollManager
|
<AgentConversationScrollManager
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
|
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
|
||||||
const schema = z.object({
|
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),
|
session_id: z.string().min(1),
|
||||||
revision: z.number().int().positive(),
|
revision: z.number().int().positive(),
|
||||||
updated_at: z.string().datetime(),
|
updated_at: z.string().datetime(),
|
||||||
@@ -12,9 +12,12 @@ const schema = z.object({
|
|||||||
id, title: z.string().min(1).max(120),
|
id, title: z.string().min(1).max(120),
|
||||||
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
|
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
|
||||||
owner: z.enum(["agent", "human"]),
|
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),
|
})).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) => {
|
}).refine((plan) => {
|
||||||
const ids = new Set(plan.nodes.map((node) => node.id));
|
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);
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { parseOperationalTask } from "./operational-task-client";
|
import {
|
||||||
|
parseOperationalTask,
|
||||||
|
parseOperationalTaskNotification
|
||||||
|
} from "./operational-task-client";
|
||||||
|
|
||||||
vi.mock("./project-client", () => ({
|
vi.mock("./project-client", () => ({
|
||||||
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })),
|
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", () => {
|
it("accepts a task without any plan or report", () => {
|
||||||
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
|
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", () => {
|
it("keeps unknown fields and invalid generations rejected", () => {
|
||||||
expect(() => parseOperationalTask(task(0))).toThrow();
|
expect(() => parseOperationalTask(task(0))).toThrow();
|
||||||
const value = task();
|
const value = task();
|
||||||
@@ -57,3 +71,38 @@ describe("operational task contract", () => {
|
|||||||
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
|
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"]),
|
kind: z.enum(["schedule", "external_system", "operator", "agent"]),
|
||||||
external_id: z.string().min(1).nullable(),
|
external_id: z.string().min(1).nullable(),
|
||||||
external_revision: z.number().int().positive().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();
|
}).strict();
|
||||||
const taskTriggerSchema = z.object({
|
const taskTriggerSchema = z.object({
|
||||||
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
|
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"]),
|
status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]),
|
||||||
detail: z.string().min(1),
|
detail: z.string().min(1),
|
||||||
evidence_refs: z.array(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();
|
}).strict();
|
||||||
const planInputSchema = z.object({
|
const planInputSchema = z.object({
|
||||||
schema: z.literal("execution-plan@1"),
|
schema: z.enum(["execution-plan@1", "execution-plan@2"]),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
reason: z.string().min(1),
|
reason: z.string().min(1),
|
||||||
nodes: z.array(planNodeSchema).min(1).max(100),
|
nodes: z.array(planNodeSchema).min(1).max(100),
|
||||||
edges: z.array(z.object({
|
edges: z.array(z.object({
|
||||||
|
id: z.string().min(1).nullish(),
|
||||||
from: z.string().min(1),
|
from: z.string().min(1),
|
||||||
to: z.string().min(1),
|
to: z.string().min(1),
|
||||||
condition: z.string().min(1).nullable()
|
condition: z.string().min(1).nullable()
|
||||||
@@ -139,6 +145,8 @@ const actionSchema = z.object({
|
|||||||
execution_request_id: z.string().uuid().nullable(),
|
execution_request_id: z.string().uuid().nullable(),
|
||||||
proposed_by: z.string().min(1),
|
proposed_by: z.string().min(1),
|
||||||
proposed_at: workbenchDateTimeSchema,
|
proposed_at: workbenchDateTimeSchema,
|
||||||
|
edited_by: z.string().min(1).nullish(),
|
||||||
|
edited_at: workbenchDateTimeSchema.nullish(),
|
||||||
decided_by: z.string().min(1).nullable(),
|
decided_by: z.string().min(1).nullable(),
|
||||||
decided_at: workbenchDateTimeSchema.nullable(),
|
decided_at: workbenchDateTimeSchema.nullable(),
|
||||||
decision_comment: z.string().min(1).nullable(),
|
decision_comment: z.string().min(1).nullable(),
|
||||||
@@ -147,6 +155,21 @@ const actionSchema = z.object({
|
|||||||
receipt: actionReceiptSchema.nullable(),
|
receipt: actionReceiptSchema.nullable(),
|
||||||
execution_detail: z.string().min(1).nullable()
|
execution_detail: z.string().min(1).nullable()
|
||||||
}).strict();
|
}).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({
|
const taskPermissionsSchema = z.object({
|
||||||
can_publish_artifacts: z.boolean(),
|
can_publish_artifacts: z.boolean(),
|
||||||
can_propose_actions: z.boolean(),
|
can_propose_actions: z.boolean(),
|
||||||
@@ -154,7 +177,8 @@ const taskPermissionsSchema = z.object({
|
|||||||
can_decide_work_orders: z.boolean(),
|
can_decide_work_orders: z.boolean(),
|
||||||
can_execute_work_orders: z.boolean(),
|
can_execute_work_orders: z.boolean(),
|
||||||
can_decide_commands: 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();
|
}).strict();
|
||||||
const externalWorkOrderActivitySchema = z.object({
|
const externalWorkOrderActivitySchema = z.object({
|
||||||
sequence: z.number().int().positive(),
|
sequence: z.number().int().positive(),
|
||||||
@@ -192,6 +216,7 @@ const operationalTaskSchema = taskSummarySchema.extend({
|
|||||||
latest_report: reportRevisionSchema.nullable(),
|
latest_report: reportRevisionSchema.nullable(),
|
||||||
evidence: z.array(evidenceSchema),
|
evidence: z.array(evidenceSchema),
|
||||||
actions: z.array(actionSchema),
|
actions: z.array(actionSchema),
|
||||||
|
access_requests: z.array(accessRequestSchema).default([]),
|
||||||
permissions: taskPermissionsSchema,
|
permissions: taskPermissionsSchema,
|
||||||
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
|
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
|
||||||
}).strict();
|
}).strict();
|
||||||
@@ -212,6 +237,11 @@ const notificationSchema = z.object({
|
|||||||
]),
|
]),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
description: 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"]),
|
status: z.enum(["unread", "read"]),
|
||||||
version: z.number().int().positive(),
|
version: z.number().int().positive(),
|
||||||
required_permission: z.string().min(1).nullable(),
|
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 OperationalTask = z.infer<typeof operationalTaskSchema>;
|
||||||
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
|
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
|
||||||
export type OperationalTaskAction = z.infer<typeof actionSchema>;
|
export type OperationalTaskAction = z.infer<typeof actionSchema>;
|
||||||
|
export type OperationalTaskAccessRequest = z.infer<typeof accessRequestSchema>;
|
||||||
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
|
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
|
||||||
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
|
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
|
||||||
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
|
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) {
|
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
return notificationPageSchema.parse(await operationalTaskRequest(
|
return notificationPageSchema.parse(await operationalTaskRequest(
|
||||||
"/operational-task-notifications?unread_only=true&limit=100&offset=0",
|
"/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";
|
import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel";
|
||||||
|
|
||||||
describe("execution step cards", () => {
|
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 = {
|
const plan: AgentExecutionPlan = {
|
||||||
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
|
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
|
||||||
updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验",
|
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: "投诉集中" }]
|
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} />);
|
const { container } = render(<ExecutionPlanSteps plan={plan} />);
|
||||||
expect(screen.getByText("并行处理")).toBeInTheDocument();
|
expect(screen.getByText(/历史流程按原始依赖展示/)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch");
|
expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("2 条独立分枝")).toBeInTheDocument();
|
|
||||||
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
|
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
|
||||||
expect(container.querySelector("script")).toBeNull();
|
expect(container.querySelector("script")).toBeNull();
|
||||||
expect(screen.queryByText(/前置:/)).not.toBeInTheDocument();
|
expect(screen.getByText("前置:上游(上游正常)、邻区(投诉集中)")).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") => ({
|
const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({
|
||||||
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
|
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
|
||||||
});
|
});
|
||||||
@@ -58,11 +54,10 @@ describe("execution step cards", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { container } = render(<ExecutionPlanSteps plan={plan} />);
|
const { container } = render(<ExecutionPlanSteps plan={plan} />);
|
||||||
expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible();
|
expect(screen.getByText("前置:上游复核、邻区投诉、阀门排查、运维记录")).toBeVisible();
|
||||||
expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible();
|
expect(screen.getByText("前置:水箱记录、现场采样")).toBeVisible();
|
||||||
expect(screen.getAllByText("分枝汇合")).toHaveLength(2);
|
expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument();
|
||||||
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
|
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 }],
|
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 }],
|
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
|
work_order_snapshot: null
|
||||||
} as OperationalTask;
|
} as OperationalTask;
|
||||||
|
|
||||||
@@ -111,11 +107,74 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
|||||||
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
|
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
render(<AgentExecutionPlanPanel {...props} />);
|
const { rerender } = render(<AgentExecutionPlanPanel {...props} />);
|
||||||
return props;
|
return { ...props, rerender };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("operational task approval and evidence", () => {
|
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", () => {
|
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
|
||||||
taskPanel(taskBase);
|
taskPanel(taskBase);
|
||||||
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
|
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ type ExecutionNode = {
|
|||||||
status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled";
|
status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled";
|
||||||
owner: "agent" | "human";
|
owner: "agent" | "human";
|
||||||
detail: string;
|
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 = {
|
type ExecutionEdge = {
|
||||||
|
id?: string | null;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
@@ -52,6 +57,9 @@ type AgentExecutionPlanPanelProps = {
|
|||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||||
onExecute: (actionId: string, expectedVersion: number) => 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>;
|
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||||
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -62,6 +70,9 @@ export function AgentExecutionPlanPanel({
|
|||||||
onContinue,
|
onContinue,
|
||||||
onDecision,
|
onDecision,
|
||||||
onExecute,
|
onExecute,
|
||||||
|
onDraftUpdate,
|
||||||
|
onConfirmDispatch,
|
||||||
|
onAccessDecision,
|
||||||
onReconcile,
|
onReconcile,
|
||||||
onLoadEvidenceSnapshot,
|
onLoadEvidenceSnapshot,
|
||||||
onRefresh
|
onRefresh
|
||||||
@@ -103,8 +114,8 @@ export function AgentExecutionPlanPanel({
|
|||||||
: "历史报告";
|
: "历史报告";
|
||||||
const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [
|
const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [
|
||||||
node.id,
|
node.id,
|
||||||
task.actions.filter((action) => action.node_id === node.id).map((action) => (
|
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} />
|
<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));
|
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>
|
</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">
|
<section aria-label="任务报告" className="surface-reading space-y-3 rounded-xl border border-slate-200/70 p-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
|
<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}
|
action={action}
|
||||||
onDecision={onDecision}
|
onDecision={onDecision}
|
||||||
onExecute={onExecute}
|
onExecute={onExecute}
|
||||||
|
onDraftUpdate={onDraftUpdate}
|
||||||
|
onConfirmDispatch={onConfirmDispatch}
|
||||||
onReconcile={onReconcile}
|
onReconcile={onReconcile}
|
||||||
onRefresh={onRefresh}
|
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> }) {
|
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 }) {
|
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
|
||||||
const layout = createBranchLayout(plan);
|
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
|
||||||
if (!layout || depth > 8) return <ExactDependencyList plan={plan} nodeActions={nodeActions} />;
|
const activeEdges = plan.edges.filter((edge) => {
|
||||||
return (
|
const source = nodeById.get(edge.from);
|
||||||
<div className="grid gap-2">
|
return source?.kind !== "decision" || source.selected_edge_id === edge.id;
|
||||||
<ExecutionChain nodes={layout.prefix} plan={plan} nodeActions={nodeActions} />
|
});
|
||||||
{layout.branches.length > 0 ? (
|
const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id));
|
||||||
<section aria-label={`并行分枝 ${layout.branches.length} 项`} data-execution-layout="branch" className="min-w-0 py-1">
|
const visible = new Set<string>();
|
||||||
<div className="mb-2 flex items-center gap-2">
|
const queue = roots.map((node) => node.id);
|
||||||
<StatusIcon tone="info" shape="circle" size="md"><GitFork size={14} /></StatusIcon>
|
while (queue.length > 0) {
|
||||||
<span className="text-xs font-semibold text-slate-800">并行处理</span>
|
const current = queue.shift()!;
|
||||||
<StatusBadge tone="info">{layout.branches.length} 条独立分枝</StatusBadge>
|
if (visible.has(current)) continue;
|
||||||
</div>
|
visible.add(current);
|
||||||
<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">
|
for (const edge of activeEdges.filter((item) => item.from === current)) queue.push(edge.to);
|
||||||
{layout.branches.map((branch, index) => {
|
}
|
||||||
const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id);
|
const ordered = topologicalNodes(plan);
|
||||||
return (
|
const alternatives = ordered.filter((node) => !visible.has(node.id));
|
||||||
<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">
|
return <div className="space-y-3">
|
||||||
<p className="mb-2 text-[11px] font-semibold text-slate-500">分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}</p>
|
<div className="space-y-2 border-l border-slate-200 pl-3">
|
||||||
<ExecutionChain nodes={branch} plan={plan} nodeActions={nodeActions} compact />
|
{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()} />)}
|
||||||
</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}
|
|
||||||
</div>
|
</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 }) {
|
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: {
|
||||||
return <div className="grid gap-0">{nodes.map((node, index) => {
|
nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[];
|
||||||
const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined;
|
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
|
||||||
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>;
|
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> }) {
|
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>;
|
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);
|
||||||
function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView {
|
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>;
|
||||||
const included = new Set(nodeIds);
|
})}</ol></div>;
|
||||||
return {
|
|
||||||
nodes: plan.nodes.filter((node) => included.has(node.id)),
|
|
||||||
edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
|
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>
|
<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="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>
|
<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>
|
<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}
|
{action ? <div className="mt-2">{action}</div> : null}
|
||||||
</div>
|
</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>;
|
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;
|
task: OperationalTask;
|
||||||
action: OperationalTaskAction;
|
action: OperationalTaskAction;
|
||||||
onDecision: AgentExecutionPlanPanelProps["onDecision"];
|
onDecision: AgentExecutionPlanPanelProps["onDecision"];
|
||||||
onExecute: AgentExecutionPlanPanelProps["onExecute"];
|
onExecute: AgentExecutionPlanPanelProps["onExecute"];
|
||||||
|
onDraftUpdate?: AgentExecutionPlanPanelProps["onDraftUpdate"];
|
||||||
|
onConfirmDispatch?: AgentExecutionPlanPanelProps["onConfirmDispatch"];
|
||||||
onReconcile: AgentExecutionPlanPanelProps["onReconcile"];
|
onReconcile: AgentExecutionPlanPanelProps["onReconcile"];
|
||||||
onRefresh: () => void;
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [stale, setStale] = useState(false);
|
const [stale, setStale] = useState(false);
|
||||||
const [rejectComment, setRejectComment] = useState("");
|
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 command = action.kind === "issue_command";
|
||||||
const canDecide = command ? task.permissions.can_decide_commands : task.permissions.can_decide_work_orders;
|
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 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);
|
setSubmitting(kind); setError(null);
|
||||||
try {
|
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 === "reconcile") await onReconcile(action.action_id, action.version);
|
||||||
else {
|
else {
|
||||||
if (kind === "reject" && !rejectComment.trim()) {
|
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>
|
<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>
|
<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} />
|
<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 ? (
|
{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">
|
<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>
|
<p className="font-semibold text-blue-700">确认依据</p>
|
||||||
@@ -409,6 +506,7 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : 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>
|
<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>
|
<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}
|
{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}
|
{!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}
|
{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">
|
<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" && 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,41 +583,6 @@ function externalStatusLabel(value: string) {
|
|||||||
return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record<string, string>)[value] ?? value;
|
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[]>) {
|
function reachableFrom(start: string, outgoing: Map<string, ExecutionEdge[]>) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const stack = [start];
|
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 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 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 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 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 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 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]; }
|
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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
|
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 { createVisibleTickerCards } from "./agent-task-ticker-utils";
|
||||||
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
|
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", () => {
|
describe("scheduled condition backend boundaries", () => {
|
||||||
afterEach(cleanup);
|
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 = {
|
const workOrder: ScheduledWorkOrderItem = {
|
||||||
id: "demo-work-order",
|
id: "demo-work-order",
|
||||||
kind: "work_order",
|
kind: "work_order",
|
||||||
code: "WO-DEMO",
|
code: "WO-DEMO",
|
||||||
|
externalStatus: "open",
|
||||||
scheduledAt: "2026-09-23T10:00:00Z",
|
scheduledAt: "2026-09-23T10:00:00Z",
|
||||||
title: "演示预约维修",
|
title: "演示预约维修",
|
||||||
summary: "验证中文时间标签",
|
summary: "验证中文时间标签",
|
||||||
@@ -52,13 +53,24 @@ describe("scheduled condition backend boundaries", () => {
|
|||||||
location: "测试点",
|
location: "测试点",
|
||||||
assignee: "维修班组",
|
assignee: "维修班组",
|
||||||
feedbackRequirements: ["回填处理结果"],
|
feedbackRequirements: ["回填处理结果"],
|
||||||
activity: [{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗",
|
activity: [
|
||||||
kind: "created", title: "工单创建", detail: "确认预约窗口。" }]
|
{ 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} />);
|
render(<WorkOrderReportPanel condition={workOrder} />);
|
||||||
expect(screen.getByText("工单创建")).toBeVisible();
|
expect(screen.getByText("工单报告")).toBeVisible();
|
||||||
|
expect(screen.getByText("工单编号:WO-DEMO")).toBeVisible();
|
||||||
|
expect(screen.getByText("阀井检修已完成。")).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();
|
expect(screen.queryByText("子任务")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ const riskTones = {
|
|||||||
critical: "danger"
|
critical: "danger"
|
||||||
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
|
||||||
|
|
||||||
|
const externalWorkOrderStatusLabels = {
|
||||||
|
draft: "待派发", open: "已分配", in_progress: "执行中",
|
||||||
|
awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消"
|
||||||
|
} as const;
|
||||||
|
|
||||||
type IncidentOperation = {
|
type IncidentOperation = {
|
||||||
target: string;
|
target: string;
|
||||||
elementType: string;
|
elementType: string;
|
||||||
@@ -109,6 +114,9 @@ type ConditionDetailPanelProps = {
|
|||||||
onContinueConversation: () => void;
|
onContinueConversation: () => void;
|
||||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => 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>;
|
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||||
onTaskRefresh?: () => void;
|
onTaskRefresh?: () => void;
|
||||||
@@ -122,18 +130,25 @@ export function StatusPill({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
|
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 (
|
return (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
tone={
|
tone={
|
||||||
isAgentEvent ? "neutral" : getStatusTone(condition.status)
|
isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
|
||||||
}
|
}
|
||||||
activity={
|
activity={
|
||||||
isAgentEvent ? "static" : getStatusActivity(condition.status)
|
isAgentEvent ? "static" : getStatusActivity(badgeStatus)
|
||||||
}
|
}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{isAgentEvent ? "事件调查" : statusLabels[condition.status]}
|
{isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +160,9 @@ export function ConditionDetailPanel({
|
|||||||
onContinueConversation,
|
onContinueConversation,
|
||||||
onTaskDecision,
|
onTaskDecision,
|
||||||
onTaskExecute,
|
onTaskExecute,
|
||||||
|
onTaskDraftUpdate,
|
||||||
|
onTaskConfirmDispatch,
|
||||||
|
onTaskAccessDecision,
|
||||||
onTaskReconcile,
|
onTaskReconcile,
|
||||||
onLoadTaskEvidenceSnapshot,
|
onLoadTaskEvidenceSnapshot,
|
||||||
onTaskRefresh
|
onTaskRefresh
|
||||||
@@ -157,7 +175,6 @@ export function ConditionDetailPanel({
|
|||||||
condition.kind === "work_order" ? (
|
condition.kind === "work_order" ? (
|
||||||
<WorkOrderDetailPanel
|
<WorkOrderDetailPanel
|
||||||
condition={condition}
|
condition={condition}
|
||||||
dataSource={condition.dataSource ?? workOrderDataSource}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConditionRecordDetailPanel
|
<ConditionRecordDetailPanel
|
||||||
@@ -167,6 +184,9 @@ export function ConditionDetailPanel({
|
|||||||
onContinueConversation={onContinueConversation}
|
onContinueConversation={onContinueConversation}
|
||||||
onTaskDecision={onTaskDecision}
|
onTaskDecision={onTaskDecision}
|
||||||
onTaskExecute={onTaskExecute}
|
onTaskExecute={onTaskExecute}
|
||||||
|
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||||
|
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||||
|
onTaskAccessDecision={onTaskAccessDecision}
|
||||||
onTaskReconcile={onTaskReconcile}
|
onTaskReconcile={onTaskReconcile}
|
||||||
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
|
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
|
||||||
onTaskRefresh={onTaskRefresh}
|
onTaskRefresh={onTaskRefresh}
|
||||||
@@ -196,6 +216,9 @@ function ConditionRecordDetailPanel({
|
|||||||
onContinueConversation,
|
onContinueConversation,
|
||||||
onTaskDecision,
|
onTaskDecision,
|
||||||
onTaskExecute,
|
onTaskExecute,
|
||||||
|
onTaskDraftUpdate,
|
||||||
|
onTaskConfirmDispatch,
|
||||||
|
onTaskAccessDecision,
|
||||||
onTaskReconcile,
|
onTaskReconcile,
|
||||||
onLoadTaskEvidenceSnapshot,
|
onLoadTaskEvidenceSnapshot,
|
||||||
onTaskRefresh
|
onTaskRefresh
|
||||||
@@ -206,6 +229,9 @@ function ConditionRecordDetailPanel({
|
|||||||
onContinueConversation: () => void;
|
onContinueConversation: () => void;
|
||||||
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
|
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
|
||||||
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
|
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
|
||||||
|
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
|
||||||
|
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
|
||||||
|
onTaskAccessDecision?: ConditionDetailPanelProps["onTaskAccessDecision"];
|
||||||
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
|
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
|
||||||
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
|
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
|
||||||
onTaskRefresh?: () => void;
|
onTaskRefresh?: () => void;
|
||||||
@@ -239,6 +265,7 @@ function ConditionRecordDetailPanel({
|
|||||||
icon={header.icon}
|
icon={header.icon}
|
||||||
iconClassName={header.iconClassName}
|
iconClassName={header.iconClassName}
|
||||||
iconSpin={header.iconSpin}
|
iconSpin={header.iconSpin}
|
||||||
|
hideDetail={condition.operationalTask?.task_type === "external_work_order"}
|
||||||
/>
|
/>
|
||||||
<DetailDecisionBody>
|
<DetailDecisionBody>
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
@@ -250,16 +277,17 @@ function ConditionRecordDetailPanel({
|
|||||||
exit={{ opacity: 0, y: -8, scale: 0.996 }}
|
exit={{ opacity: 0, y: -8, scale: 0.996 }}
|
||||||
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
|
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
|
<AgentExecutionPlanPanel
|
||||||
task={condition.operationalTask}
|
task={condition.operationalTask}
|
||||||
onContinue={onContinueConversation}
|
onContinue={onContinueConversation}
|
||||||
@@ -271,6 +299,18 @@ function ConditionRecordDetailPanel({
|
|||||||
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
|
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
|
||||||
return onTaskExecute(condition.operationalTask!.task_id, actionId, version);
|
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) => {
|
onReconcile={(actionId, version) => {
|
||||||
if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。"));
|
if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。"));
|
||||||
return onTaskReconcile(condition.operationalTask!.task_id, actionId, version);
|
return onTaskReconcile(condition.operationalTask!.task_id, actionId, version);
|
||||||
@@ -439,13 +479,7 @@ function SourceBoundaryNotice({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkOrderDetailPanel({
|
function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
|
||||||
condition,
|
|
||||||
dataSource
|
|
||||||
}: {
|
|
||||||
condition: ScheduledWorkOrderItem;
|
|
||||||
dataSource: WorkbenchWorkOrderDataSource;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<DetailScroll>
|
<DetailScroll>
|
||||||
<DetailShell>
|
<DetailShell>
|
||||||
@@ -453,23 +487,10 @@ function WorkOrderDetailPanel({
|
|||||||
condition={condition}
|
condition={condition}
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
iconClassName="bg-blue-50 text-blue-700"
|
iconClassName="bg-blue-50 text-blue-700"
|
||||||
|
hideDetail
|
||||||
/>
|
/>
|
||||||
<DetailDecisionBody>
|
<DetailDecisionBody>
|
||||||
<SourceBoundaryNotice
|
<WorkOrderReportPanel condition={condition} />
|
||||||
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
|
|
||||||
description={dataSource === "backend"
|
|
||||||
? "工单状态、负责人、回执要求与活动记录均来自后端持久化记录。"
|
|
||||||
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
|
|
||||||
/>
|
|
||||||
<WorkOrderActivityPanel condition={condition} />
|
|
||||||
<DecisionActionStrip
|
|
||||||
icon={CalendarClock}
|
|
||||||
title="工单跟踪"
|
|
||||||
value={condition.recommendation ?? "按工单活动记录跟踪现场处置。"}
|
|
||||||
actionLabel={condition.status === "awaiting_reply" ? "待复令" : "跟踪工单"}
|
|
||||||
actionDisabled
|
|
||||||
actionTitle="工单流程由调度和现场班组流转"
|
|
||||||
/>
|
|
||||||
</DetailDecisionBody>
|
</DetailDecisionBody>
|
||||||
</DetailShell>
|
</DetailShell>
|
||||||
</DetailScroll>
|
</DetailScroll>
|
||||||
@@ -562,13 +583,15 @@ type DetailHeaderProps = {
|
|||||||
icon: typeof FileText;
|
icon: typeof FileText;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
iconSpin?: boolean;
|
iconSpin?: boolean;
|
||||||
|
hideDetail?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DetailHeader({
|
function DetailHeader({
|
||||||
condition,
|
condition,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
iconSpin = false
|
iconSpin = false,
|
||||||
|
hideDetail = false
|
||||||
}: DetailHeaderProps) {
|
}: DetailHeaderProps) {
|
||||||
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
|
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
|
||||||
const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks";
|
const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks";
|
||||||
@@ -593,7 +616,7 @@ function DetailHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-1 text-base font-semibold leading-5 text-slate-950">{condition.title}</h3>
|
<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>
|
||||||
</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 (
|
return (
|
||||||
<DetailSectionCard>
|
<div className="space-y-2.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<DetailSectionCard className="overflow-hidden p-0">
|
||||||
<SectionTitle icon={ClipboardList} title="预约工单" />
|
<div className="border-b border-slate-200/70 px-3 py-3">
|
||||||
<StatusBadge tone={getStatusTone(condition.status)}>{statusLabels[condition.status]}</StatusBadge>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
</div>
|
<div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
|
<p className="text-[10px] font-semibold text-slate-400">工单报告</p>
|
||||||
<ReportMetaTile label="来源" value={condition.source} />
|
<SectionTitle icon={ClipboardList} title="任务执行" />
|
||||||
<ReportMetaTile label="负责人" value={condition.assignee ?? "待分配"} />
|
</div>
|
||||||
<ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
|
<StatusBadge tone={getStatusTone(condition.status)}>{progress.label}</StatusBadge>
|
||||||
<ReportMetaTile label="预约时间" value={formatTime(condition.scheduledAt)} />
|
</div>
|
||||||
</div>
|
<p className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{condition.summary}</p>
|
||||||
{condition.location ? <p className="mt-3 text-xs text-slate-600">位置:{condition.location}</p> : null}
|
<p className="mt-1 break-all text-[11px] text-slate-500">工单编号:{condition.code}</p>
|
||||||
{condition.feedbackRequirements.length ? (
|
<section aria-label="派单信息" className="mt-3">
|
||||||
<section className="mt-3 border-t border-slate-200/70 pt-3">
|
<h4 className="mb-1.5 text-[11px] font-semibold text-slate-500">派单信息</h4>
|
||||||
<h4 className="text-xs font-semibold text-slate-700">回执要求</h4>
|
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
|
||||||
<ul className="mt-2 grid gap-1.5">
|
<WorkOrderField label="来源" value={condition.source} />
|
||||||
{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>)}
|
<WorkOrderField label="位置" value={condition.location ?? "未填写"} />
|
||||||
</ul>
|
<WorkOrderField label="派发人" value={condition.dispatcher === "外围系统同步" ? "未记录" : condition.dispatcher} />
|
||||||
</section>
|
<WorkOrderField label="执行班组" value={condition.assignee ?? "待分配"} />
|
||||||
) : null}
|
<WorkOrderField label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
|
||||||
<section className="mt-3 border-t border-slate-200/70 pt-3">
|
<WorkOrderField label="预约时间" value={formatWorkOrderScheduledAt(condition)} />
|
||||||
<h4 className="text-xs font-semibold text-slate-700">活动记录</h4>
|
</dl>
|
||||||
{condition.activity.length ? (
|
</section>
|
||||||
<ol className="mt-2 border-l border-blue-200 pl-4">
|
</div>
|
||||||
{[...condition.activity].sort((a, b) => b.sequence - a.sequence).map((entry) => (
|
{progress.cancelled ? (
|
||||||
<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">
|
<p className="px-3 py-3 text-xs text-slate-600">工单已取消,后续办理步骤不适用。</p>
|
||||||
<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>
|
<ol aria-label="工单进度" className="grid gap-2 px-3 py-3">
|
||||||
<p className="mt-1 text-[10px] text-slate-400">{entry.actor}</p>
|
{steps.map((step, index) => <TaskExecutionStepRow key={step.id} step={step} isLast={index === steps.length - 1} />)}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
</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 {
|
function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem {
|
||||||
return {
|
return {
|
||||||
id: snapshot.work_order_id,
|
id: snapshot.work_order_id,
|
||||||
kind: "work_order",
|
kind: "work_order",
|
||||||
code: snapshot.scenario_code ?? snapshot.work_order_id,
|
code: snapshot.scenario_code ?? snapshot.work_order_id,
|
||||||
|
externalStatus: snapshot.status,
|
||||||
|
workOrderScheduledAt: snapshot.scheduled_at,
|
||||||
scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at,
|
scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at,
|
||||||
title: snapshot.title,
|
title: snapshot.title,
|
||||||
summary: snapshot.summary,
|
summary: snapshot.summary,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarPlus,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -30,11 +29,6 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
|
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 { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
|
||||||
import {
|
import {
|
||||||
WORKBENCH_TASK_CATEGORIES,
|
WORKBENCH_TASK_CATEGORIES,
|
||||||
@@ -69,14 +63,6 @@ export type ScheduledConditionFeedPresentation =
|
|||||||
| "desktop-floating"
|
| "desktop-floating"
|
||||||
| "mobile-sheet";
|
| "mobile-sheet";
|
||||||
|
|
||||||
export type ScheduleCreateRequest = {
|
|
||||||
scheduleKey: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
intervalSeconds: number;
|
|
||||||
nextRunAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScheduledConditionFeedProps = {
|
type ScheduledConditionFeedProps = {
|
||||||
presentation?: ScheduledConditionFeedPresentation;
|
presentation?: ScheduledConditionFeedPresentation;
|
||||||
conditions?: ScheduledConditionItem[];
|
conditions?: ScheduledConditionItem[];
|
||||||
@@ -99,11 +85,13 @@ type ScheduledConditionFeedProps = {
|
|||||||
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
|
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
|
||||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => 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>;
|
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||||
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||||
onTaskRefresh?: () => void;
|
onTaskRefresh?: () => void;
|
||||||
onRefresh?: () => void | Promise<void>;
|
onRefresh?: () => void | Promise<void>;
|
||||||
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScheduledConditionFeedFocusRequest = {
|
type ScheduledConditionFeedFocusRequest = {
|
||||||
@@ -133,11 +121,13 @@ export function ScheduledConditionFeed({
|
|||||||
onOpenTaskCollaboration,
|
onOpenTaskCollaboration,
|
||||||
onTaskDecision,
|
onTaskDecision,
|
||||||
onTaskExecute,
|
onTaskExecute,
|
||||||
|
onTaskDraftUpdate,
|
||||||
|
onTaskConfirmDispatch,
|
||||||
|
onTaskAccessDecision,
|
||||||
onTaskReconcile,
|
onTaskReconcile,
|
||||||
onLoadTaskEvidenceSnapshot,
|
onLoadTaskEvidenceSnapshot,
|
||||||
onTaskRefresh,
|
onTaskRefresh,
|
||||||
onRefresh,
|
onRefresh
|
||||||
onCreateSchedule
|
|
||||||
}: ScheduledConditionFeedProps) {
|
}: ScheduledConditionFeedProps) {
|
||||||
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
|
||||||
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
|
||||||
@@ -147,15 +137,6 @@ export function ScheduledConditionFeed({
|
|||||||
const [statusFilter, setStatusFilter] =
|
const [statusFilter, setStatusFilter] =
|
||||||
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
|
||||||
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
|
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 [refreshing, setRefreshing] = useState(false);
|
||||||
const refreshingRef = useRef(false);
|
const refreshingRef = useRef(false);
|
||||||
const mobileSheet = presentation === "mobile-sheet";
|
const mobileSheet = presentation === "mobile-sheet";
|
||||||
@@ -300,7 +281,6 @@ export function ScheduledConditionFeed({
|
|||||||
|
|
||||||
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
|
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
|
||||||
setTaskCategory(nextCategory);
|
setTaskCategory(nextCategory);
|
||||||
if (nextCategory !== "scheduled") setScheduleOpen(false);
|
|
||||||
setTaskFilter(CONDITION_FILTER_ALL);
|
setTaskFilter(CONDITION_FILTER_ALL);
|
||||||
setStatusFilter(CONDITION_FILTER_ALL);
|
setStatusFilter(CONDITION_FILTER_ALL);
|
||||||
updateSelectedConditionId(
|
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() {
|
async function handleRefresh() {
|
||||||
if (!onRefresh || refreshingRef.current) return;
|
if (!onRefresh || refreshingRef.current) return;
|
||||||
refreshingRef.current = true;
|
refreshingRef.current = true;
|
||||||
@@ -474,17 +426,6 @@ export function ScheduledConditionFeed({
|
|||||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
|
<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>
|
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
|
||||||
</div>
|
</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 ? (
|
{onRefresh ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -542,26 +483,6 @@ export function ScheduledConditionFeed({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"scheduled-feed-layout mt-3 grid min-h-0",
|
"scheduled-feed-layout mt-3 grid min-h-0",
|
||||||
@@ -595,6 +516,9 @@ export function ScheduledConditionFeed({
|
|||||||
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
onContinueConversation={() => handleContinueConversation(selectedCondition)}
|
||||||
onTaskDecision={onTaskDecision}
|
onTaskDecision={onTaskDecision}
|
||||||
onTaskExecute={onTaskExecute}
|
onTaskExecute={onTaskExecute}
|
||||||
|
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||||
|
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||||
|
onTaskAccessDecision={onTaskAccessDecision}
|
||||||
onTaskReconcile={onTaskReconcile}
|
onTaskReconcile={onTaskReconcile}
|
||||||
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
|
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
|
||||||
onTaskRefresh={onTaskRefresh}
|
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 = {
|
type ConditionTimelinePanelProps = {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
presentation: ScheduledConditionFeedPresentation;
|
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,
|
latest_report: null,
|
||||||
evidence: [],
|
evidence: [],
|
||||||
actions: [],
|
actions: [],
|
||||||
|
access_requests: [],
|
||||||
permissions: {
|
permissions: {
|
||||||
can_publish_artifacts: false,
|
can_publish_artifacts: false,
|
||||||
can_propose_actions: false,
|
can_propose_actions: false,
|
||||||
@@ -59,7 +60,8 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
|
|||||||
can_decide_work_orders: false,
|
can_decide_work_orders: false,
|
||||||
can_execute_work_orders: false,
|
can_execute_work_orders: false,
|
||||||
can_decide_commands: false,
|
can_decide_commands: false,
|
||||||
can_execute_commands: false
|
can_execute_commands: false,
|
||||||
|
can_decide_access: false
|
||||||
},
|
},
|
||||||
work_order_snapshot: {
|
work_order_snapshot: {
|
||||||
work_order_id: "44444444-4444-4444-8444-444444444444",
|
work_order_id: "44444444-4444-4444-8444-444444444444",
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
createOperationalRequestId,
|
createOperationalRequestId,
|
||||||
createOperationalTask,
|
createOperationalTask,
|
||||||
decideOperationalTaskAction,
|
decideOperationalTaskAction,
|
||||||
|
decideOperationalTaskAccess,
|
||||||
|
updateOperationalWorkOrderDraft,
|
||||||
|
confirmOperationalWorkOrderDispatch,
|
||||||
executeOperationalTaskAction,
|
executeOperationalTaskAction,
|
||||||
fetchOperationalTask,
|
fetchOperationalTask,
|
||||||
fetchOperationalTaskEvidenceSnapshot,
|
fetchOperationalTaskEvidenceSnapshot,
|
||||||
@@ -72,6 +75,7 @@ export function useOperationalTasks(
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||||
const forceRefreshRef = useRef(false);
|
const forceRefreshRef = useRef(false);
|
||||||
|
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
forceRefreshRef.current = true;
|
forceRefreshRef.current = true;
|
||||||
@@ -84,7 +88,9 @@ export function useOperationalTasks(
|
|||||||
const summaries = sync.snapshot?.tasks ?? [];
|
const summaries = sync.snapshot?.tasks ?? [];
|
||||||
try {
|
try {
|
||||||
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
|
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
|
||||||
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
|
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
|
||||||
const details = await Promise.all(changed.map((task) =>
|
const details = await Promise.all(changed.map((task) =>
|
||||||
@@ -98,8 +104,11 @@ export function useOperationalTasks(
|
|||||||
});
|
});
|
||||||
tasksRef.current = nextTasks;
|
tasksRef.current = nextTasks;
|
||||||
forceRefreshRef.current = false;
|
forceRefreshRef.current = false;
|
||||||
|
detailResetGenerationRef.current = sync.detailResetGeneration;
|
||||||
setTasks(nextTasks);
|
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);
|
setAvailable(true);
|
||||||
activatedRef.current = true;
|
activatedRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -118,7 +127,7 @@ export function useOperationalTasks(
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [getAccessToken, refreshGeneration, sync.snapshot]);
|
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const events = syncEvents;
|
const events = syncEvents;
|
||||||
@@ -234,6 +243,45 @@ export function useOperationalTasks(
|
|||||||
return action;
|
return action;
|
||||||
}, [getAccessToken]);
|
}, [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) => {
|
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
||||||
await acknowledgeOperationalTaskNotification(
|
await acknowledgeOperationalTaskNotification(
|
||||||
notificationId,
|
notificationId,
|
||||||
@@ -261,6 +309,9 @@ export function useOperationalTasks(
|
|||||||
decideAction,
|
decideAction,
|
||||||
executeAction,
|
executeAction,
|
||||||
reconcileAction,
|
reconcileAction,
|
||||||
|
updateWorkOrderDraft,
|
||||||
|
confirmWorkOrderDispatch,
|
||||||
|
decideAccess,
|
||||||
acknowledgeNotification,
|
acknowledgeNotification,
|
||||||
loadEvidenceSnapshot
|
loadEvidenceSnapshot
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function useWorkbenchAgent({
|
|||||||
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
|
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
|
||||||
|
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
|
||||||
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
||||||
const [sessionTitle, setSessionTitle] = useState("新对话");
|
const [sessionTitle, setSessionTitle] = useState("新对话");
|
||||||
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
||||||
@@ -131,6 +132,7 @@ export function useWorkbenchAgent({
|
|||||||
const clearSessionId = useCallback(() => {
|
const clearSessionId = useCallback(() => {
|
||||||
sessionIdRef.current = null;
|
sessionIdRef.current = null;
|
||||||
setSessionId(null);
|
setSessionId(null);
|
||||||
|
setSessionTaskId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
||||||
@@ -549,6 +551,7 @@ export function useWorkbenchAgent({
|
|||||||
|
|
||||||
applySessionId(loadedSession.sessionId);
|
applySessionId(loadedSession.sessionId);
|
||||||
setSessionPurpose(loadedSession.purpose);
|
setSessionPurpose(loadedSession.purpose);
|
||||||
|
setSessionTaskId(loadedSession.taskId ?? null);
|
||||||
setSessionTitle(loadedSession.title);
|
setSessionTitle(loadedSession.title);
|
||||||
setStatusLabel("Agent 历史会话已打开");
|
setStatusLabel("Agent 历史会话已打开");
|
||||||
setPermissionOverrides({});
|
setPermissionOverrides({});
|
||||||
@@ -632,6 +635,7 @@ export function useWorkbenchAgent({
|
|||||||
clearSessionId();
|
clearSessionId();
|
||||||
setSessionTitle("新对话");
|
setSessionTitle("新对话");
|
||||||
setSessionPurpose("ordinary_chat");
|
setSessionPurpose("ordinary_chat");
|
||||||
|
setSessionTaskId(null);
|
||||||
setStatusLabel(nextStatusLabel);
|
setStatusLabel(nextStatusLabel);
|
||||||
setConnectionStatus("online");
|
setConnectionStatus("online");
|
||||||
setPermissionOverrides({});
|
setPermissionOverrides({});
|
||||||
@@ -917,6 +921,7 @@ export function useWorkbenchAgent({
|
|||||||
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionPurpose,
|
sessionPurpose,
|
||||||
|
sessionTaskId,
|
||||||
sessionTitle,
|
sessionTitle,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
streamRenderState,
|
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[];
|
events: WorkbenchEvent[];
|
||||||
status: "connecting" | "live" | "reconnecting";
|
status: "connecting" | "live" | "reconnecting";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
detailResetGeneration: number;
|
||||||
acknowledgeEventsThrough: (eventSequence: number) => void;
|
acknowledgeEventsThrough: (eventSequence: number) => void;
|
||||||
refresh: () => Promise<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 {
|
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
||||||
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
|
||||||
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
|
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
|
||||||
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
|
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [detailResetGeneration, setDetailResetGeneration] = useState(0);
|
||||||
const cursorRef = useRef(0);
|
const cursorRef = useRef(0);
|
||||||
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
|
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
|
||||||
const runtimeRefreshRef = useRef<number | undefined>(undefined);
|
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) => {
|
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
|
||||||
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
|
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
|
||||||
}, []);
|
}, []);
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const refreshSnapshot = refreshSnapshotRef.current;
|
const refreshSnapshot = refreshSnapshotRef.current;
|
||||||
if (!refreshSnapshot) throw new Error("工作台同步尚未就绪");
|
if (!refreshSnapshot) throw new Error("工作台同步尚未就绪");
|
||||||
await refreshSnapshot();
|
await refreshSnapshot(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,12 +59,24 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
let reconcileTimer: number | undefined;
|
let reconcileTimer: number | undefined;
|
||||||
let retryMs = 1_000;
|
let retryMs = 1_000;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
let snapshotRequestGeneration = 0;
|
||||||
|
|
||||||
const loadSnapshot = async () => {
|
const loadSnapshot = async (resetTaskDetails = false) => {
|
||||||
|
const requestGeneration = ++snapshotRequestGeneration;
|
||||||
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
|
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;
|
cursorRef.current = next.eventCursor;
|
||||||
|
if (resetTaskDetails) setEvents([]);
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
|
if (resetTaskDetails) setDetailResetGeneration((current) => current + 1);
|
||||||
setError(null);
|
setError(null);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
};
|
};
|
||||||
@@ -69,7 +93,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
after: cursorRef.current,
|
after: cursorRef.current,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onReset: loadSnapshot,
|
onReset: () => loadSnapshot(true),
|
||||||
onEvents: (nextEvents) => {
|
onEvents: (nextEvents) => {
|
||||||
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
|
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
|
||||||
if (unseen.length === 0) return;
|
if (unseen.length === 0) return;
|
||||||
@@ -116,5 +140,13 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
|
|||||||
};
|
};
|
||||||
}, [getAccessToken]);
|
}, [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 { MapDevPanel } from "./components/map-dev-panel";
|
||||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||||
import { FeaturePopover } from "./components/feature-popover";
|
import { FeaturePopover } from "./components/feature-popover";
|
||||||
import {
|
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||||
ScheduledConditionFeed,
|
import { TaskConversationReview } from "./components/task-conversation-review";
|
||||||
type ScheduleCreateRequest
|
|
||||||
} from "./components/scheduled-condition-feed";
|
|
||||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||||
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +103,6 @@ import type {
|
|||||||
DetailFeature,
|
DetailFeature,
|
||||||
ScheduledConditionItem,
|
ScheduledConditionItem,
|
||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
ScheduledConditionTaskId,
|
|
||||||
WorkbenchAlert,
|
WorkbenchAlert,
|
||||||
WorkbenchUser
|
WorkbenchUser
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -115,17 +112,6 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
|||||||
|
|
||||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
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({
|
export function MapWorkbenchPage({
|
||||||
user = WORKBENCH_USER,
|
user = WORKBENCH_USER,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -164,9 +150,6 @@ export function MapWorkbenchPage({
|
|||||||
const [layerVisibility, setLayerVisibility] =
|
const [layerVisibility, setLayerVisibility] =
|
||||||
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
||||||
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
||||||
const [createdMockScheduledTasks, setCreatedMockScheduledTasks] = useState<
|
|
||||||
ScheduledConditionRecord[]
|
|
||||||
>([]);
|
|
||||||
const workbenchSync = useWorkbenchSync(getAccessToken);
|
const workbenchSync = useWorkbenchSync(getAccessToken);
|
||||||
const workbenchSyncReconnecting =
|
const workbenchSyncReconnecting =
|
||||||
workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null;
|
workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null;
|
||||||
@@ -228,7 +211,6 @@ export function MapWorkbenchPage({
|
|||||||
getAccessToken
|
getAccessToken
|
||||||
});
|
});
|
||||||
const scheduledConditions = useMemo(() => [
|
const scheduledConditions = useMemo(() => [
|
||||||
...createdMockScheduledTasks,
|
|
||||||
...runtimeScheduledConditions,
|
...runtimeScheduledConditions,
|
||||||
...(operationalTasks.available
|
...(operationalTasks.available
|
||||||
? operationalTasks.conditions.filter((condition) =>
|
? operationalTasks.conditions.filter((condition) =>
|
||||||
@@ -236,30 +218,7 @@ export function MapWorkbenchPage({
|
|||||||
|| condition.operationalTask?.task_type === "external_work_order"
|
|| condition.operationalTask?.task_type === "external_work_order"
|
||||||
)
|
)
|
||||||
: [])
|
: [])
|
||||||
], [createdMockScheduledTasks, operationalTasks.available, operationalTasks.conditions, runtimeScheduledConditions]);
|
], [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;
|
|
||||||
}, []);
|
|
||||||
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
|
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
|
||||||
const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
|
const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
|
||||||
const route = getOperationalNotificationRoute(notification.kind);
|
const route = getOperationalNotificationRoute(notification.kind);
|
||||||
@@ -1097,6 +1056,9 @@ export function MapWorkbenchPage({
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
|
}, [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<
|
const agentPanelProps: Omit<
|
||||||
ComponentProps<typeof AgentCommandPanel>,
|
ComponentProps<typeof AgentCommandPanel>,
|
||||||
"collapsing" | "onCollapse"
|
"collapsing" | "onCollapse"
|
||||||
@@ -1116,6 +1078,14 @@ export function MapWorkbenchPage({
|
|||||||
selectedModel: agent.selectedModel,
|
selectedModel: agent.selectedModel,
|
||||||
approvalMode: agent.approvalMode,
|
approvalMode: agent.approvalMode,
|
||||||
uiResults: activeAgentUiResults,
|
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,
|
workspaceContext: agentWorkspaceContext,
|
||||||
prompt: agent.promptDraft,
|
prompt: agent.promptDraft,
|
||||||
onRefreshHistory: agent.refreshSessionHistory,
|
onRefreshHistory: agent.refreshSessionHistory,
|
||||||
@@ -1348,11 +1318,13 @@ export function MapWorkbenchPage({
|
|||||||
onOpenTaskCollaboration={handleOpenTaskCollaboration}
|
onOpenTaskCollaboration={handleOpenTaskCollaboration}
|
||||||
onTaskDecision={operationalTasks.decideAction}
|
onTaskDecision={operationalTasks.decideAction}
|
||||||
onTaskExecute={operationalTasks.executeAction}
|
onTaskExecute={operationalTasks.executeAction}
|
||||||
|
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
|
||||||
|
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
|
||||||
|
onTaskAccessDecision={operationalTasks.decideAccess}
|
||||||
onTaskReconcile={operationalTasks.reconcileAction}
|
onTaskReconcile={operationalTasks.reconcileAction}
|
||||||
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
||||||
onTaskRefresh={operationalTasks.refresh}
|
onTaskRefresh={operationalTasks.refresh}
|
||||||
onRefresh={workbenchSync.refresh}
|
onRefresh={workbenchSync.refresh}
|
||||||
onCreateSchedule={handleCreateMockScheduledTask}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1496,11 +1468,13 @@ export function MapWorkbenchPage({
|
|||||||
onOpenTaskCollaboration={handleOpenTaskCollaboration}
|
onOpenTaskCollaboration={handleOpenTaskCollaboration}
|
||||||
onTaskDecision={operationalTasks.decideAction}
|
onTaskDecision={operationalTasks.decideAction}
|
||||||
onTaskExecute={operationalTasks.executeAction}
|
onTaskExecute={operationalTasks.executeAction}
|
||||||
|
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
|
||||||
|
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
|
||||||
|
onTaskAccessDecision={operationalTasks.decideAccess}
|
||||||
onTaskReconcile={operationalTasks.reconcileAction}
|
onTaskReconcile={operationalTasks.reconcileAction}
|
||||||
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
||||||
onTaskRefresh={operationalTasks.refresh}
|
onTaskRefresh={operationalTasks.refresh}
|
||||||
onRefresh={workbenchSync.refresh}
|
onRefresh={workbenchSync.refresh}
|
||||||
onCreateSchedule={handleCreateMockScheduledTask}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</MobileWorkbenchSheet>
|
</MobileWorkbenchSheet>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -16,7 +16,7 @@ import { FieldSelect } from "@/shared/ui/field-select";
|
|||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
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 { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
|
||||||
import type { ScheduledWorkOrderItem } from "../types";
|
import type { ScheduledWorkOrderItem } from "../types";
|
||||||
import type {
|
import type {
|
||||||
@@ -123,6 +123,10 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
|
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
|
||||||
const [eventDescription, setEventDescription] = useState("");
|
const [eventDescription, setEventDescription] = useState("");
|
||||||
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
|
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 = () => {
|
const clear = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -139,12 +143,34 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
setProgressNote("");
|
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();
|
clear();
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const edit = (item: PeripheralWorkOrder) => {
|
const edit = (item: PeripheralWorkOrder, trigger: HTMLButtonElement) => {
|
||||||
|
editorTriggerRef.current = trigger;
|
||||||
|
setDeleteCandidateId(null);
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
setTitle(item.title);
|
setTitle(item.title);
|
||||||
@@ -193,6 +219,81 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canSave = canWrite && !busy && title.trim() && summary.trim();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -205,7 +306,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
|
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
|
||||||
<Sparkles size={13} />投诉与爆管上报
|
<Sparkles size={13} />投诉与爆管上报
|
||||||
</Button>
|
</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} />新增预约工单
|
<Plus size={13} />新增预约工单
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,68 +339,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
</WorkbenchNotice>
|
</WorkbenchNotice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editorOpen ? (
|
{editorOpen && !editing ? orderEditor : null}
|
||||||
<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}
|
|
||||||
|
|
||||||
<section aria-label="工单列表" className="mt-3">
|
<section aria-label="工单列表" className="mt-3">
|
||||||
{orders.length ? (
|
{orders.length ? (
|
||||||
@@ -311,9 +351,15 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
|
|||||||
index={index}
|
index={index}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onEdit={() => edit(item)}
|
editing={editing?.work_order_id === item.work_order_id}
|
||||||
onDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
|
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);
|
await client.deleteWorkOrder(item.work_order_id, item.revision);
|
||||||
|
setDeleteCandidateId(null);
|
||||||
if (editing?.work_order_id === item.work_order_id) clear();
|
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;
|
item: PeripheralWorkOrder;
|
||||||
index: number;
|
index: number;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
busy: string | null;
|
busy: string | null;
|
||||||
onEdit: () => void;
|
editing: boolean;
|
||||||
onDelete: () => void;
|
editor: ReactNode;
|
||||||
|
onEdit: (trigger: HTMLButtonElement) => void;
|
||||||
|
confirmingDelete: boolean;
|
||||||
|
onRequestDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const workOrder = toScheduledWorkOrder(item);
|
const workOrder = toScheduledWorkOrder(item);
|
||||||
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const cancelDelete = () => {
|
||||||
|
onCancelDelete();
|
||||||
|
deleteButtonRef.current?.focus();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
|
<article className="rounded-xl bg-slate-50 p-3 ring-1 ring-inset ring-slate-200">
|
||||||
<div className="flex items-start gap-2">
|
<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={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>
|
<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>
|
</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>
|
<p className="mt-1 text-[11px] leading-4 text-slate-500">{item.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-0.5">
|
{!editing ? <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 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 size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={onDelete}><Trash2 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>
|
</div> : null}
|
||||||
</div>
|
</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">
|
<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>
|
<span>{priorityLabel(item.priority)}优先级</span>
|
||||||
{item.location ? <span>位置:{item.location}</span> : null}
|
{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>
|
<span className="font-mono">r{item.revision}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<WorkOrderActivityPanel condition={workOrder} />
|
<WorkOrderReportPanel condition={workOrder} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -373,6 +455,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
|
|||||||
id: item.work_order_id,
|
id: item.work_order_id,
|
||||||
kind: "work_order",
|
kind: "work_order",
|
||||||
code: item.scenario_code ?? item.work_order_id,
|
code: item.scenario_code ?? item.work_order_id,
|
||||||
|
externalStatus: item.status,
|
||||||
|
workOrderScheduledAt: item.scheduled_at,
|
||||||
scheduledAt: item.scheduled_at ?? item.updated_at,
|
scheduledAt: item.scheduled_at ?? item.updated_at,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
summary: item.summary,
|
summary: item.summary,
|
||||||
@@ -381,7 +465,7 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
|
|||||||
updatedAt: Date.parse(item.updated_at),
|
updatedAt: Date.parse(item.updated_at),
|
||||||
detail: item.summary,
|
detail: item.summary,
|
||||||
source: "外围工单测试",
|
source: "外围工单测试",
|
||||||
dispatcher: item.updated_by,
|
dispatcher: "未记录",
|
||||||
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
|
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
|
||||||
dataSource: "backend",
|
dataSource: "backend",
|
||||||
location: item.location ?? undefined,
|
location: item.location ?? undefined,
|
||||||
@@ -392,8 +476,8 @@ function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorHeading({ title, onClose }: { title: string; onClose: () => void }) {
|
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="关闭编辑表单" onClick={onClose}><X size={14} /></Button></div>;
|
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 = [
|
const WORK_ORDER_STATUS_OPTIONS = [
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export type ScheduledConditionRecord = ScheduledConditionBase<ScheduledCondition
|
|||||||
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
export type ScheduledWorkOrderItem = ScheduledConditionBase<ScheduledWorkOrderStatus> & {
|
||||||
kind: "work_order";
|
kind: "work_order";
|
||||||
code: string;
|
code: string;
|
||||||
|
externalStatus?: "draft" | "open" | "in_progress" | "awaiting_reply" | "completed" | "cancelled";
|
||||||
|
workOrderScheduledAt?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
dispatcher: string;
|
dispatcher: string;
|
||||||
priority: "normal" | "urgent";
|
priority: "normal" | "urgent";
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
|
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||||
|
const taskId = "82000000-0000-4000-8000-000000000001";
|
||||||
|
const actionId = "83000000-0000-4000-8000-000000000001";
|
||||||
|
const accessId = "84000000-0000-4000-8000-000000000001";
|
||||||
|
const now = "2026-09-24T08:00:00Z";
|
||||||
|
|
||||||
|
test("动态调查显示选定路线与嵌套并行,并完成授权和工单草稿确认", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await mockAgentApi(page);
|
||||||
|
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "text/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
||||||
|
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
||||||
|
TJWATER_MAP_URL: "http://map.test/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
|
||||||
|
})}` }));
|
||||||
|
await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: {
|
||||||
|
items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0
|
||||||
|
} }));
|
||||||
|
await page.route("http://server.test/api/v1/scada-devices**", (route) => route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } }));
|
||||||
|
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
|
||||||
|
|
||||||
|
const planNode = (id: string, title: string, kind: "activity" | "decision" | "parallel_split" | "parallel_join" | "gate", status: "completed" | "waiting" | "skipped") => ({
|
||||||
|
id, title, owner: "agent" as const, status, detail: `${title}的执行记录`, kind,
|
||||||
|
evidence_refs: [], action_refs: [], selected_edge_id: null, gate_ref: null, revisits_node_id: null
|
||||||
|
});
|
||||||
|
const plan = {
|
||||||
|
schema: "execution-plan@2" as const, title: "水质投诉动态调查", reason: "按投诉位置和风险规划",
|
||||||
|
nodes: [
|
||||||
|
{ ...planNode("risk", "判断紧急风险", "decision", "completed"), selected_edge_id: "urgent-route" },
|
||||||
|
planNode("urgent", "立即现场核查", "activity", "completed"),
|
||||||
|
planNode("routine", "常规现场核查", "activity", "skipped"),
|
||||||
|
planNode("split", "同步核对现场与数据", "parallel_split", "completed"),
|
||||||
|
planNode("field", "现场采样", "activity", "waiting"),
|
||||||
|
planNode("data-split", "多源数据核对", "parallel_split", "completed"),
|
||||||
|
planNode("upstream", "上游监测", "activity", "completed"),
|
||||||
|
planNode("network", "管网运行", "activity", "completed"),
|
||||||
|
planNode("data-join", "数据证据汇合", "parallel_join", "completed"),
|
||||||
|
planNode("join", "现场与数据汇合", "parallel_join", "waiting"),
|
||||||
|
{ ...planNode("gate", "确认采样工单", "gate", "waiting"), gate_ref: actionId }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: "urgent-route", from: "risk", to: "urgent", condition: "有紧急风险" },
|
||||||
|
{ id: "routine-route", from: "risk", to: "routine", condition: "无紧急风险" },
|
||||||
|
{ id: "e1", from: "urgent", to: "split", condition: null },
|
||||||
|
{ id: "e2", from: "split", to: "field", condition: "现场" },
|
||||||
|
{ id: "e3", from: "split", to: "data-split", condition: "Agent 数据" },
|
||||||
|
{ id: "e4", from: "field", to: "join", condition: null },
|
||||||
|
{ id: "e5", from: "data-split", to: "upstream", condition: "上游" },
|
||||||
|
{ id: "e6", from: "data-split", to: "network", condition: "管网" },
|
||||||
|
{ id: "e7", from: "upstream", to: "data-join", condition: null },
|
||||||
|
{ id: "e8", from: "network", to: "data-join", condition: null },
|
||||||
|
{ id: "e9", from: "data-join", to: "join", condition: null },
|
||||||
|
{ id: "e10", from: "join", to: "gate", condition: null }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let task: OperationalTask = {
|
||||||
|
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||||
|
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
|
||||||
|
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
|
||||||
|
title: "水质投诉动态调查", summary: "小区供水有异味", source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
|
||||||
|
trigger: { kind: "manual", occurred_at: now, detail: "演示小区供水投诉" }, scheduled_for: null,
|
||||||
|
agent_session_id: "dynamic-flow-session", agent_lease_expires_at: null, current_plan_revision: 1, current_report_revision: null,
|
||||||
|
created_at: now, updated_at: now,
|
||||||
|
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session", published_at: now, published_by: "agent", plan },
|
||||||
|
latest_report: null, evidence: [],
|
||||||
|
access_requests: [{ access_request_id: accessId, task_id: taskId, node_id: "data-split", scopes: ["scada_devices", "scada_readings"],
|
||||||
|
reason: "需要核对上游设备和读数", status: "pending", approved_scopes: [], version: 1,
|
||||||
|
requested_by: "agent", requested_at: now, decided_by: null, decided_at: null, comment: null }],
|
||||||
|
actions: [{ action_id: actionId, task_id: taskId, version: 1, kind: "create_work_order", node_id: "gate", reason: "现场采样需要派单",
|
||||||
|
payload: { title: "旧采样草稿", description: "现场采样", work_order_type: "water_quality_complaint", priority: "urgent", location: "演示小区" },
|
||||||
|
evidence_refs: [], 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: now,
|
||||||
|
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: true, can_execute_work_orders: true, can_decide_commands: false, can_execute_commands: false, can_decide_access: true },
|
||||||
|
work_order_snapshot: null
|
||||||
|
};
|
||||||
|
const summary = (item: OperationalTask): OperationalTaskSummary => {
|
||||||
|
const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
|
||||||
|
void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
||||||
|
schema: "workbench-sync-snapshot@1", event_cursor: 0,
|
||||||
|
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured", artifact_availability: "not_configured",
|
||||||
|
schedule_availability: "not_configured", work_order_availability: "not_configured", alert_availability: "not_configured",
|
||||||
|
generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
|
||||||
|
tasks: [summary(task)], notifications: [], generated_at: now
|
||||||
|
} }));
|
||||||
|
await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" }));
|
||||||
|
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }));
|
||||||
|
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
const method = route.request().method();
|
||||||
|
if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } });
|
||||||
|
if (method === "GET" && path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } });
|
||||||
|
if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task });
|
||||||
|
if (method === "POST" && path.endsWith(`/access-requests/${accessId}/decision`)) {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
expect(body.approved_scopes).toEqual(["scada_devices"]);
|
||||||
|
const access = { ...task.access_requests[0], version: 2, status: "approved" as const, approved_scopes: body.approved_scopes, decided_by: "reviewer", decided_at: now };
|
||||||
|
task = { ...task, access_requests: [access] };
|
||||||
|
return route.fulfill({ json: access });
|
||||||
|
}
|
||||||
|
if (method === "PATCH" && path.endsWith(`/actions/${actionId}/draft`)) {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
expect(body.payload.title).toBe("现场采样修订稿");
|
||||||
|
const action = { ...task.actions[0], version: 2, payload: body.payload, edited_by: "reviewer", edited_at: now };
|
||||||
|
task = { ...task, actions: [action] };
|
||||||
|
return route.fulfill({ json: action });
|
||||||
|
}
|
||||||
|
if (method === "POST" && path.endsWith(`/actions/${actionId}/confirm-dispatch`)) {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
expect(body.expected_version).toBe(2);
|
||||||
|
const action = { ...task.actions[0], version: 4, approval_status: "approved" as const, execution_status: "succeeded" as const,
|
||||||
|
decided_by: "reviewer", decided_at: now, executed_by: "reviewer", executed_at: now,
|
||||||
|
execution_request_id: "86000000-0000-4000-8000-000000000001",
|
||||||
|
receipt: { adapter: "work-order", receipt_id: "WO-20260924-1", submitted_at: now, completed_at: now, external_status: "completed", detail: "采样工单已创建" } };
|
||||||
|
task = { ...task, actions: [action] };
|
||||||
|
return route.fulfill({ json: action });
|
||||||
|
}
|
||||||
|
return route.fulfill({ status: 404, json: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||||
|
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
|
const expand = panel.getByRole("button", { name: "展开工况任务" });
|
||||||
|
if (await expand.isVisible()) await expand.click();
|
||||||
|
await panel.getByRole("button", { name: /调查任务/ }).click();
|
||||||
|
await panel.getByText("水质投诉动态调查", { exact: true }).first().click();
|
||||||
|
await expect(panel.getByText(/路线选择:/).first().locator("..")).toContainText("有紧急风险");
|
||||||
|
await expect(panel.getByLabel("并行处理 2 路")).toHaveCount(2);
|
||||||
|
await expect(panel.locator('[data-execution-node="data-join"]')).toHaveCount(1);
|
||||||
|
await expect(panel.getByText("其他规划路线 1 项")).toBeVisible();
|
||||||
|
await testInfo.attach("dynamic-investigation-desktop", {
|
||||||
|
body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png"
|
||||||
|
});
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Agent 协作" }).click();
|
||||||
|
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||||
|
const review = agentPanel.getByRole("region", { name: "当前任务审核" });
|
||||||
|
await expect(review).toContainText("需要核对上游设备和读数");
|
||||||
|
await page.setViewportSize({ width: 375, height: 720 });
|
||||||
|
const mobileAgentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||||
|
if (!await mobileAgentSheet.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||||
|
await expect(mobileAgentSheet.getByRole("region", { name: "当前任务审核" })).toBeVisible();
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await expect(review).toBeVisible();
|
||||||
|
await review.getByLabel("SCADA 监测读数").uncheck();
|
||||||
|
await review.getByRole("button", { name: "批准所选范围" }).click();
|
||||||
|
await expect(panel.getByText("已授权:SCADA 设备")).toBeVisible();
|
||||||
|
await review.getByRole("button", { name: "编辑草稿" }).click();
|
||||||
|
await review.getByRole("textbox", { name: "工单标题" }).fill("现场采样修订稿");
|
||||||
|
await review.getByRole("button", { name: "保存草稿" }).click();
|
||||||
|
await expect(panel.getByText("现场采样修订稿").first()).toBeVisible();
|
||||||
|
await review.getByRole("button", { name: "确认并下发工单" }).click();
|
||||||
|
await expect(panel.getByRole("region", { name: "执行回执" })).toContainText("WO-20260924-1");
|
||||||
|
|
||||||
|
for (const width of [375, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 720 });
|
||||||
|
const mobilePanel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||||
|
if (!await mobilePanel.isVisible()) await page.getByRole("button", { name: "打开工况任务", exact: true }).click();
|
||||||
|
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||||
|
if (await sheet.getAttribute("data-snap") === "half") {
|
||||||
|
await page.getByRole("button", { name: /工况任务抽屉高度/ }).click();
|
||||||
|
}
|
||||||
|
await expect(sheet).toHaveAttribute("data-snap", "full");
|
||||||
|
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
|
||||||
|
if (await mobileExpand.isVisible()) await mobileExpand.click();
|
||||||
|
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
|
||||||
|
await expect(mobilePanel.getByLabel("并行处理 2 路")).toHaveCount(2);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
|
await mobilePanel.locator('[data-execution-node="split"]').scrollIntoViewIfNeeded();
|
||||||
|
await testInfo.attach(`dynamic-investigation-${width}`, {
|
||||||
|
body: await sheet.screenshot({ path: testInfo.outputPath(`dynamic-investigation-${width}.png`) }), contentType: "image/png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -125,30 +125,29 @@ test.describe("mobile workbench sheet", () => {
|
|||||||
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps refresh to the right of schedule on a 320px viewport", async ({ page }) => {
|
for (const width of [320, 375]) {
|
||||||
await page.setViewportSize({ width: 320, height: 700 });
|
test(`keeps refresh accessible without a schedule-create action at ${width}px`, async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.setViewportSize({ width, height: 700 });
|
||||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||||
|
|
||||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||||
const scheduleButton = sheet.getByRole("button", { name: "配置定时流程" });
|
const refreshButton = sheet.getByRole("button", { name: "刷新工况任务" });
|
||||||
const refreshButton = sheet.getByRole("button", { name: "刷新工况任务" });
|
await expect(sheet.getByRole("button", { name: "配置定时流程" })).toHaveCount(0);
|
||||||
await expect(scheduleButton).toBeVisible();
|
await expect(refreshButton).toBeVisible();
|
||||||
await expect(refreshButton).toBeVisible();
|
|
||||||
|
|
||||||
const [sheetBox, scheduleBox, refreshBox] = await Promise.all([
|
const [sheetBox, refreshBox] = await Promise.all([
|
||||||
sheet.boundingBox(),
|
sheet.boundingBox(),
|
||||||
scheduleButton.boundingBox(),
|
refreshButton.boundingBox()
|
||||||
refreshButton.boundingBox()
|
]);
|
||||||
]);
|
expect(sheetBox).not.toBeNull();
|
||||||
expect(sheetBox).not.toBeNull();
|
expect(refreshBox).not.toBeNull();
|
||||||
expect(scheduleBox).not.toBeNull();
|
expect(refreshBox!.x).toBeGreaterThanOrEqual(sheetBox!.x);
|
||||||
expect(refreshBox).not.toBeNull();
|
expect(refreshBox!.x + refreshBox!.width).toBeLessThanOrEqual(
|
||||||
expect(refreshBox!.x).toBeGreaterThan(scheduleBox!.x);
|
sheetBox!.x + sheetBox!.width
|
||||||
expect(refreshBox!.x + refreshBox!.width).toBeLessThanOrEqual(
|
);
|
||||||
sheetBox!.x + sheetBox!.width
|
});
|
||||||
);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
|
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
@@ -162,7 +161,7 @@ test.describe("mobile workbench sheet", () => {
|
|||||||
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
|
await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }) => {
|
test("keeps the waiting-for-reply state readable in the narrow sheet", async ({ page }, testInfo) => {
|
||||||
await mockFlatWorkOrder(page, "reply");
|
await mockFlatWorkOrder(page, "reply");
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.getByRole("button", { name: "打开工况任务" }).click();
|
await page.getByRole("button", { name: "打开工况任务" }).click();
|
||||||
@@ -179,7 +178,15 @@ test.describe("mobile workbench sheet", () => {
|
|||||||
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
|
await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible();
|
||||||
await awaitingReplyWorkOrder.click();
|
await awaitingReplyWorkOrder.click();
|
||||||
await expect(sheet.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
|
await expect(sheet.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
|
||||||
await expect(sheet.getByText("活动记录", { exact: true })).toBeVisible();
|
await expect(sheet.getByText("工单报告", { exact: true })).toBeVisible();
|
||||||
|
await expect(sheet.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await sheet.getByText("工单报告", { exact: true }).scrollIntoViewIfNeeded();
|
||||||
|
await sheet.screenshot({ path: testInfo.outputPath("work-order-mobile-report.png") });
|
||||||
|
await sheet.getByRole("list", { name: "工单进度" }).locator("li").nth(1).scrollIntoViewIfNeeded();
|
||||||
|
await sheet.screenshot({ path: testInfo.outputPath("work-order-mobile-stages.png") });
|
||||||
|
await sheet.getByLabel("回执信息").scrollIntoViewIfNeeded();
|
||||||
|
await sheet.screenshot({ path: testInfo.outputPath("work-order-mobile-receipt.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
|
test("clears mobile sheet state when crossing into the desktop layout", async ({ page }) => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
|
|||||||
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
|
||||||
TJWATER_AUTH_MODE: "disabled",
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
TJWATER_SERVER_API_BASE_URL: "http://server.test",
|
||||||
|
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://sample.test",
|
||||||
TJWATER_MAP_URL: "http://map.test/geoserver",
|
TJWATER_MAP_URL: "http://map.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
|
||||||
})}`
|
})}`
|
||||||
@@ -280,7 +281,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
|
|||||||
await expect(page.getByText("正在重连,业务数据可能陈旧", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("正在重连,业务数据可能陈旧", { exact: true })).toHaveCount(0);
|
||||||
await page.evaluate(() => document.fonts.ready);
|
await page.evaluate(() => document.fonts.ready);
|
||||||
await testInfo.attach("workbench-reconnect-notice", {
|
await testInfo.attach("workbench-reconnect-notice", {
|
||||||
body: await reconnectNotice.screenshot({ path: testInfo.outputPath("workbench-reconnect-notice.png") }),
|
body: await page.screenshot({ path: testInfo.outputPath("workbench-reconnect-notice.png") }),
|
||||||
contentType: "image/png"
|
contentType: "image/png"
|
||||||
});
|
});
|
||||||
let conditionPanel = await openTask();
|
let conditionPanel = await openTask();
|
||||||
@@ -296,23 +297,24 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
|
|||||||
const refreshingButton = conditionPanel.getByRole("button", { name: "正在刷新工况任务" });
|
const refreshingButton = conditionPanel.getByRole("button", { name: "正在刷新工况任务" });
|
||||||
await expect(refreshingButton).toBeDisabled();
|
await expect(refreshingButton).toBeDisabled();
|
||||||
await refreshingButton.evaluate((button: HTMLButtonElement) => button.click());
|
await refreshingButton.evaluate((button: HTMLButtonElement) => button.click());
|
||||||
await page.waitForTimeout(500);
|
|
||||||
expect(snapshotRequests).toBe(2);
|
expect(snapshotRequests).toBe(2);
|
||||||
await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 });
|
await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 });
|
||||||
expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900);
|
expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900);
|
||||||
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
|
await expect(conditionPanel.getByRole("button", { name: "配置定时流程" })).toHaveCount(0);
|
||||||
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
|
await expect(conditionPanel.getByRole("button", { name: "创建定时流程" })).toHaveCount(0);
|
||||||
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
|
|
||||||
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
|
|
||||||
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
|
|
||||||
await conditionPanel.getByRole("button", { name: /预约任务/ }).click();
|
await conditionPanel.getByRole("button", { name: /预约任务/ }).click();
|
||||||
await expect(conditionPanel.getByText("最近工况", { exact: true })).toHaveCount(1);
|
await expect(conditionPanel.getByText("最近工况", { exact: true })).toHaveCount(1);
|
||||||
await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible();
|
await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible();
|
||||||
await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click();
|
await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click();
|
||||||
await expect(conditionPanel.getByText("回执要求", { exact: true })).toBeVisible();
|
await expect(conditionPanel.getByText("复令要求", { exact: true })).toBeVisible();
|
||||||
await expect(conditionPanel.getByText("回填现场处理结果", { exact: true })).toBeVisible();
|
await expect(conditionPanel.getByText("回填现场处理结果", { exact: true })).toBeVisible();
|
||||||
|
const appointmentDetail = conditionPanel.getByTestId("scheduled-condition-detail-scroll");
|
||||||
|
await expect(appointmentDetail.getByText("工单报告", { exact: true })).toBeVisible();
|
||||||
|
await expect(appointmentDetail.getByText("派单信息", { exact: true })).toBeVisible();
|
||||||
|
await expect(appointmentDetail.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(appointmentDetail.getByText("关键证据", { exact: true })).toHaveCount(0);
|
||||||
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
|
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
|
||||||
await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).toBeVisible();
|
await expect(appointmentDetail.getByText("联调测试点东侧", { exact: true })).toBeVisible();
|
||||||
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
|
await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible();
|
||||||
await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0);
|
await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0);
|
||||||
await page.waitForTimeout(3_500);
|
await page.waitForTimeout(3_500);
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ test.describe("外围工单测试", () => {
|
|||||||
await expect(workOrderList).toContainText("待处理");
|
await expect(workOrderList).toContainText("待处理");
|
||||||
await expect(workOrderList).toContainText("处理中");
|
await expect(workOrderList).toContainText("处理中");
|
||||||
await expect(workOrderList).toContainText("已完成");
|
await expect(workOrderList).toContainText("已完成");
|
||||||
await expect(workOrderList.getByText("暂无活动记录;不根据工单状态推测处置过程。", { exact: true })).toHaveCount(3);
|
await expect(workOrderList.getByText("工单报告", { exact: true })).toHaveCount(3);
|
||||||
|
await expect(workOrderList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
|
||||||
|
await expect(workOrderList.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
expect(requestedPaths).toEqual(expect.arrayContaining([
|
expect(requestedPaths).toEqual(expect.arrayContaining([
|
||||||
"/api/sample/v1/context",
|
"/api/sample/v1/context",
|
||||||
"/api/sample/v1/work-orders"
|
"/api/sample/v1/work-orders"
|
||||||
@@ -240,6 +242,7 @@ test.describe("外围工单测试", () => {
|
|||||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||||
let workOrders: Record<string, unknown>[] = [];
|
let workOrders: Record<string, unknown>[] = [];
|
||||||
const writes: Record<string, unknown>[] = [];
|
const writes: Record<string, unknown>[] = [];
|
||||||
|
let deleteRequests = 0;
|
||||||
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
await page.route("**/runtime-config.js", (route) => route.fulfill({
|
||||||
contentType: "text/javascript",
|
contentType: "text/javascript",
|
||||||
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test", TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test" })}`
|
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({ TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test", TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test" })}`
|
||||||
@@ -269,7 +272,7 @@ test.describe("外围工单测试", () => {
|
|||||||
workOrders = [next];
|
workOrders = [next];
|
||||||
return route.fulfill({ json: workOrders[0] });
|
return route.fulfill({ json: workOrders[0] });
|
||||||
}
|
}
|
||||||
if (path.includes("/work-orders/") && request.method() === "DELETE") { workOrders = []; return route.fulfill({ json: { resource_type: "work_order", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
|
if (path.includes("/work-orders/") && request.method() === "DELETE") { deleteRequests += 1; workOrders = []; return route.fulfill({ json: { resource_type: "work_order", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
|
||||||
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
|
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,6 +302,12 @@ test.describe("外围工单测试", () => {
|
|||||||
|
|
||||||
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
|
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
|
||||||
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
|
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
|
||||||
|
await expect(panel.getByLabel("工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
|
||||||
|
await expect(editor.locator("xpath=ancestor::article")).toContainText("合成预约维修");
|
||||||
|
await expect(editor.getByRole("textbox", { name: "标题" })).toBeFocused();
|
||||||
|
await expect(panel.getByRole("button", { name: "编辑工单 合成预约维修" })).toHaveCount(0);
|
||||||
|
await expect(panel.getByRole("button", { name: "删除工单 合成预约维修" })).toHaveCount(0);
|
||||||
|
expect(await editor.getByRole("textbox", { name: "标题" }).evaluate((input) => getComputedStyle(input).outlineWidth)).toBe("0px");
|
||||||
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
|
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
|
||||||
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
|
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
|
||||||
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
|
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
|
||||||
@@ -323,13 +332,14 @@ test.describe("外围工单测试", () => {
|
|||||||
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
|
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
|
||||||
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
|
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
|
||||||
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
|
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
|
||||||
|
await expect(refreshedEditor.locator("xpath=ancestor::article")).toContainText("外部系统更新后的预约维修");
|
||||||
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
|
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
|
||||||
await expect(refreshedEditor.getByRole("textbox", { name: "位置", exact: true })).toHaveValue("联调测试点东侧");
|
await expect(refreshedEditor.getByRole("textbox", { name: "位置", exact: true })).toHaveValue("联调测试点东侧");
|
||||||
await expect(refreshedEditor.getByRole("textbox", { name: "回执要求(每行一项)" })).toHaveValue("回传现场处理结果\n上传照片");
|
await expect(refreshedEditor.getByRole("textbox", { name: "回执要求(每行一项)" })).toHaveValue("回传现场处理结果\n上传照片");
|
||||||
|
|
||||||
for (const width of [1280, 375, 320]) {
|
for (const width of [1280, 375, 320]) {
|
||||||
await page.setViewportSize({ width, height: 780 });
|
await page.setViewportSize({ width, height: 780 });
|
||||||
await refreshedEditor.scrollIntoViewIfNeeded();
|
await refreshedEditor.locator("xpath=ancestor::article").evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
await testInfo.attach(`peripheral-work-order-process-${width}`, {
|
await testInfo.attach(`peripheral-work-order-process-${width}`, {
|
||||||
body: await page.screenshot({ path: `/tmp/tjwater-operational-ui-review/peripheral-work-order-edit-${width}.png` }),
|
body: await page.screenshot({ path: `/tmp/tjwater-operational-ui-review/peripheral-work-order-edit-${width}.png` }),
|
||||||
@@ -338,7 +348,34 @@ test.describe("外围工单测试", () => {
|
|||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 1280, height: 780 });
|
await page.setViewportSize({ width: 1280, height: 780 });
|
||||||
await refreshedEditor.getByRole("button", { name: "关闭编辑表单" }).click();
|
await refreshedEditor.getByRole("button", { name: "关闭编辑表单" }).click();
|
||||||
|
await expect(panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" })).toBeFocused();
|
||||||
|
await expect(panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" })).toBeVisible();
|
||||||
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
|
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
|
||||||
|
const deletePrompt = panel.getByRole("group", { name: "确认删除工单 外部系统更新后的预约维修" });
|
||||||
|
await expect(deletePrompt).toBeVisible();
|
||||||
|
await expect(deletePrompt).toContainText("对应的工况任务将在后台同步后移出任务列表");
|
||||||
|
expect(deleteRequests).toBe(0);
|
||||||
|
await deletePrompt.getByRole("button", { name: "取消" }).click();
|
||||||
|
await expect(deletePrompt).toHaveCount(0);
|
||||||
|
expect(deleteRequests).toBe(0);
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(deletePrompt).toHaveCount(0);
|
||||||
|
expect(deleteRequests).toBe(0);
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
|
||||||
|
for (const width of [1280, 375, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 780 });
|
||||||
|
await deletePrompt.scrollIntoViewIfNeeded();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
|
await testInfo.attach(`peripheral-work-order-delete-prompt-${width}`, {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deletePrompt.getByRole("button", { name: "确认删除" }).click();
|
||||||
|
expect(deleteRequests).toBe(1);
|
||||||
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeHidden();
|
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeHidden();
|
||||||
|
|
||||||
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
|
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
|
||||||
@@ -526,8 +563,8 @@ test.describe("外围工单测试", () => {
|
|||||||
await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible();
|
await expect(conditionPanel.getByText("调查处理流程", { exact: true })).toBeVisible();
|
||||||
await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible();
|
await expect(conditionPanel.getByText(/已完成 5 项 · 执行中 3 项 · 待人工或资料 3 项/)).toBeVisible();
|
||||||
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
|
await expect(conditionPanel.locator('[data-execution-node]')).toHaveCount(13);
|
||||||
await expect(conditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
|
await expect(conditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible();
|
||||||
await expect(conditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
|
await expect(conditionPanel.getByText(/并行处理/)).toHaveCount(0);
|
||||||
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
|
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
|
||||||
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
|
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
|
||||||
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
|
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
|
||||||
@@ -549,7 +586,7 @@ test.describe("外围工单测试", () => {
|
|||||||
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
|
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
|
||||||
contentType: "image/png"
|
contentType: "image/png"
|
||||||
});
|
});
|
||||||
await conditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
|
await conditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded();
|
||||||
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
|
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
|
||||||
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
|
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
|
||||||
contentType: "image/png"
|
contentType: "image/png"
|
||||||
@@ -572,8 +609,7 @@ test.describe("外围工单测试", () => {
|
|||||||
await expect(responsiveWorkflowStep).toBeVisible();
|
await expect(responsiveWorkflowStep).toBeVisible();
|
||||||
await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
|
await responsiveConditionPanel.getByText("调查处理流程", { exact: true }).scrollIntoViewIfNeeded();
|
||||||
await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13);
|
await expect(responsiveConditionPanel.locator('[data-execution-node]')).toHaveCount(13);
|
||||||
await expect(responsiveConditionPanel.getByLabel("并行分枝 4 项")).toBeVisible();
|
await expect(responsiveConditionPanel.getByText(/历史流程按原始依赖展示/)).toBeVisible();
|
||||||
await expect(responsiveConditionPanel.getByLabel("并行分枝 2 项")).toBeVisible();
|
|
||||||
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
|
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
|
||||||
await page.waitForTimeout(350);
|
await page.waitForTimeout(350);
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||||
@@ -581,7 +617,7 @@ test.describe("外围工单测试", () => {
|
|||||||
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
|
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
|
||||||
contentType: "image/png"
|
contentType: "image/png"
|
||||||
});
|
});
|
||||||
await responsiveConditionPanel.getByLabel("并行分枝 2 项").scrollIntoViewIfNeeded();
|
await responsiveConditionPanel.locator('[data-execution-node="conclusion"]').scrollIntoViewIfNeeded();
|
||||||
await page.evaluate(() => document.fonts.ready);
|
await page.evaluate(() => document.fonts.ready);
|
||||||
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
|
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
|
||||||
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
|
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
|
||||||
@@ -618,8 +654,7 @@ test.describe("外围工单测试", () => {
|
|||||||
await recovered.getByRole("button", { name: /调查任务/ }).click();
|
await recovered.getByRole("button", { name: /调查任务/ }).click();
|
||||||
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
|
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
|
||||||
await taskRow.click();
|
await taskRow.click();
|
||||||
await expect(recovered.getByLabel("并行分枝 4 项")).toBeVisible();
|
await expect(recovered.getByText(/历史流程按原始依赖展示/)).toBeVisible();
|
||||||
await expect(recovered.getByLabel("并行分枝 2 项")).toBeVisible();
|
|
||||||
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
|
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -201,9 +201,10 @@ test("keeps execution spinners without breathing badges after selecting timeline
|
|||||||
await expectRunningStatusAnimations(executingWorkOrder);
|
await expectRunningStatusAnimations(executingWorkOrder);
|
||||||
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
|
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
|
||||||
await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
|
await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
|
||||||
await expect(detail.getByText("回执要求", { exact: true })).toBeVisible();
|
await expect(detail.getByText("复令要求", { exact: true })).toBeVisible();
|
||||||
await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible();
|
await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible();
|
||||||
await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
|
await expect(detail.getByText("工单报告", { exact: true })).toBeVisible();
|
||||||
|
await expect(detail.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
await expect(detail.locator("details")).toHaveCount(0);
|
await expect(detail.locator("details")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +237,8 @@ test("shows completed field work as a static waiting-for-reply state", async ({
|
|||||||
|
|
||||||
await awaitingReplyWorkOrder.click();
|
await awaitingReplyWorkOrder.click();
|
||||||
await expect(panel.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
|
await expect(panel.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible();
|
||||||
await expect(panel.getByText("活动记录", { exact: true })).toBeVisible();
|
await expect(panel.getByText("工单报告", { exact: true })).toBeVisible();
|
||||||
|
await expect(panel.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
panel
|
panel
|
||||||
.getByTestId("scheduled-condition-detail-scroll")
|
.getByTestId("scheduled-condition-detail-scroll")
|
||||||
|
|||||||
@@ -456,9 +456,9 @@ test("keeps mock conditions while using backend work orders and alerts", async (
|
|||||||
await expect(panel).not.toContainText("工单演示回退");
|
await expect(panel).not.toContainText("工单演示回退");
|
||||||
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
|
await panel.getByRole("button", { name: /后端工单:北区阀门复核/ }).click();
|
||||||
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
|
const detail = panel.getByTestId("scheduled-condition-detail-scroll");
|
||||||
await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible();
|
await expect(detail.getByText("工单报告", { exact: true })).toBeVisible();
|
||||||
await expect(detail.getByText("回传阀门开度", { exact: true })).toBeVisible();
|
await expect(detail.getByText("回传阀门开度", { exact: true })).toBeVisible();
|
||||||
await expect(detail.getByText("活动记录", { exact: true })).toBeVisible();
|
await expect(detail.getByText("活动记录", { exact: true })).toHaveCount(0);
|
||||||
await expect(detail.locator("details")).toHaveCount(0);
|
await expect(detail.locator("details")).toHaveCount(0);
|
||||||
|
|
||||||
const alertButton = page.getByRole("button", {
|
const alertButton = page.getByRole("button", {
|
||||||
|
|||||||
Reference in New Issue
Block a user