feat(workbench): refine dynamic task collaboration

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent b64d2b78bb
commit 87cc7785da
25 changed files with 1216 additions and 470 deletions
@@ -6,7 +6,7 @@ import type { OperationalTask } from "../api/operational-task-client";
import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel";
describe("execution step cards", () => {
it("shows parallel cards, exact join dependencies and escaped model content", () => {
it("shows exact historical dependencies without assigning an unrecorded branch type", () => {
const plan: AgentExecutionPlan = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验",
@@ -17,19 +17,15 @@ describe("execution step cards", () => {
edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByText("并行处理")).toBeInTheDocument();
expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch");
expect(screen.getByText("2 条独立分枝")).toBeInTheDocument();
expect(screen.getByText(/历史流程按原始依赖展示/)).toBeInTheDocument();
expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument();
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
expect(container.querySelector("script")).toBeNull();
expect(screen.queryByText(/前置:/)).not.toBeInTheDocument();
expect(screen.getByText("分枝 1")).toBeInTheDocument();
expect(screen.getByText("分枝 2")).toBeInTheDocument();
expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument();
expect(screen.getByText("前置:上游(上游正常)、邻区(投诉集中)")).toBeInTheDocument();
expect(screen.getByText("待人工/资料")).toBeInTheDocument();
});
it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => {
it("keeps all historical nodes and their recorded dependencies", () => {
const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
});
@@ -58,11 +54,10 @@ describe("execution step cards", () => {
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible();
expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible();
expect(screen.getAllByText("分枝汇合")).toHaveLength(2);
expect(screen.getByText("前置:上游复核、邻区投诉、阀门排查、运维记录")).toBeVisible();
expect(screen.getByText("前置:水箱记录、现场采样")).toBeVisible();
expect(screen.queryByText(/并行处理/)).not.toBeInTheDocument();
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument();
});
});
@@ -96,7 +91,8 @@ const taskBase = {
},
evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }],
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false },
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false, can_decide_access: false },
access_requests: [],
work_order_snapshot: null
} as OperationalTask;
@@ -111,11 +107,74 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
...overrides
};
render(<AgentExecutionPlanPanel {...props} />);
return props;
const { rerender } = render(<AgentExecutionPlanPanel {...props} />);
return { ...props, rerender };
}
describe("operational task approval and evidence", () => {
it("shows the selected v2 route first and keeps unchosen work available", () => {
const node = (id: string, title: string, status: "completed" | "waiting" | "skipped") => ({
id, title, status, owner: "agent" as const, detail: title
});
const plan = {
nodes: [
{ ...node("risk", "判断紧急风险", "completed"), kind: "decision" as const, selected_edge_id: "urgent" },
{ ...node("urgent", "立即现场采样", "waiting"), kind: "activity" as const },
{ ...node("routine", "常规核查", "skipped"), kind: "activity" as const }
],
edges: [
{ id: "urgent", from: "risk", to: "urgent", label: "有紧急风险" },
{ id: "routine", from: "risk", to: "routine", label: "无紧急风险" }
]
};
render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByText(/路线选择:/).parentElement).toHaveTextContent("有紧急风险");
expect(screen.getByText("其他规划路线 1 项")).toBeVisible();
expect(screen.getAllByText("立即现场采样")[0]).toBeVisible();
});
it("groups nested parallel work at its recorded joins", () => {
const node = (id: string, kind: "activity" | "decision" | "parallel_split" | "parallel_join") => ({
id, title: id, detail: `${id}详情`, owner: "agent" as const, status: "completed" as const, kind
});
const plan = {
nodes: [node("start", "parallel_split"), node("field", "activity"), node("agent", "activity"),
node("read", "parallel_split"), node("upstream", "activity"), node("network", "activity"),
node("evidence", "parallel_join"), node("result", "parallel_join")],
edges: [
{ id: "e1", from: "start", to: "field", condition: "现场" },
{ id: "e2", from: "start", to: "agent", condition: "Agent" },
{ id: "e3", from: "field", to: "result" },
{ id: "e4", from: "agent", to: "read" },
{ id: "e5", from: "read", to: "upstream", condition: "上游" },
{ id: "e6", from: "read", to: "network", condition: "管网" },
{ id: "e7", from: "upstream", to: "evidence" },
{ id: "e8", from: "network", to: "evidence" },
{ id: "e9", from: "evidence", to: "result" }
]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getAllByLabelText("并行处理 2 路")).toHaveLength(2);
expect(container.querySelectorAll('[data-execution-node="evidence"]')).toHaveLength(1);
expect(container.querySelectorAll('[data-execution-node="result"]')).toHaveLength(1);
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
});
it("lets a reviewer edit a work-order draft before one-click dispatch", async () => {
const onDraftUpdate = vi.fn(async () => undefined);
const onConfirmDispatch = vi.fn(async () => undefined);
const initialTask = { ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], payload: { title: "旧草稿", description: "现场采样", work_order_type: "general", priority: "normal" } }] };
const props = taskPanel(initialTask, { onDraftUpdate, onConfirmDispatch });
fireEvent.click(screen.getByRole("button", { name: "编辑草稿" }));
fireEvent.change(screen.getByRole("textbox", { name: "工单标题" }), { target: { value: "人工修订草稿" } });
fireEvent.click(screen.getByRole("button", { name: "保存草稿" }));
await waitFor(() => expect(onDraftUpdate).toHaveBeenCalledWith(taskBase.actions[0].action_id, 3, expect.objectContaining({ title: "人工修订草稿" })));
await waitFor(() => expect(screen.getByRole("button", { name: "确认并下发工单" })).toBeDisabled());
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...initialTask, actions: [{ ...initialTask.actions[0], version: 4, payload: { ...initialTask.actions[0].payload, title: "人工修订草稿" } }] }} />);
fireEvent.click(screen.getByRole("button", { name: "确认并下发工单" }));
await waitFor(() => expect(onConfirmDispatch).toHaveBeenCalledWith(taskBase.actions[0].action_id, 4));
});
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
taskPanel(taskBase);
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
@@ -33,9 +33,14 @@ type ExecutionNode = {
status: "pending" | "running" | "waiting" | "completed" | "failed" | "skipped" | "cancelled";
owner: "agent" | "human";
detail: string;
kind?: "activity" | "decision" | "parallel_split" | "parallel_join" | "gate" | null;
selected_edge_id?: string | null;
gate_ref?: string | null;
revisits_node_id?: string | null;
};
type ExecutionEdge = {
id?: string | null;
from: string;
to: string;
label?: string | null;
@@ -52,6 +57,9 @@ type AgentExecutionPlanPanelProps = {
onContinue: () => void;
onDecision: (actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onExecute: (actionId: string, expectedVersion: number) => Promise<unknown>;
onDraftUpdate?: (actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch?: (actionId: string, expectedVersion: number) => Promise<unknown>;
onAccessDecision?: (requestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
@@ -62,6 +70,9 @@ export function AgentExecutionPlanPanel({
onContinue,
onDecision,
onExecute,
onDraftUpdate,
onConfirmDispatch,
onAccessDecision,
onReconcile,
onLoadEvidenceSnapshot,
onRefresh
@@ -103,8 +114,8 @@ export function AgentExecutionPlanPanel({
: "历史报告";
const nodeActions = plan ? Object.fromEntries(plan.nodes.map((node) => [
node.id,
task.actions.filter((action) => action.node_id === node.id).map((action) => (
<HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} onExecute={onExecute} onReconcile={onReconcile} onRefresh={onRefresh} />
task.actions.filter((action) => action.node_id === node.id).map((action) => (
<HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision} onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />
))
])) : {};
const unlinkedActions = task.actions.filter((action) => !action.node_id || !plan?.nodes.some((node) => node.id === action.node_id));
@@ -138,6 +149,12 @@ export function AgentExecutionPlanPanel({
)}
</section>
{task.access_requests.length > 0 ? <section aria-label="数据访问审核" className="space-y-2">
{task.access_requests.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={plan?.nodes.find((node) => node.id === request.node_id)?.title}
canDecide={task.permissions.can_decide_access} onDecision={onAccessDecision} onRefresh={onRefresh} />)}
</section> : null}
<section aria-label="任务报告" className="surface-reading space-y-3 rounded-xl border border-slate-200/70 p-3">
<div>
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><FileText size={13} aria-hidden="true" />任务报告</h3>
@@ -181,6 +198,8 @@ export function AgentExecutionPlanPanel({
action={action}
onDecision={onDecision}
onExecute={onExecute}
onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch}
onReconcile={onReconcile}
onRefresh={onRefresh}
/>
@@ -214,62 +233,85 @@ function ReportList({ title, items, tone }: { title: string; items: string[]; to
}
export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record<string, ReactNode> }) {
return <div aria-label="调查执行步骤" className="px-3 py-3"><ExecutionFlow plan={plan} nodeActions={nodeActions} depth={0} /></div>;
return <div aria-label="调查执行步骤" className="px-3 py-3">{
plan.nodes.some((node) => node.kind)
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} />
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
}</div>;
}
function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; depth: number }) {
const layout = createBranchLayout(plan);
if (!layout || depth > 8) return <ExactDependencyList plan={plan} nodeActions={nodeActions} />;
return (
<div className="grid gap-2">
<ExecutionChain nodes={layout.prefix} plan={plan} nodeActions={nodeActions} />
{layout.branches.length > 0 ? (
<section aria-label={`并行分枝 ${layout.branches.length} 项`} data-execution-layout="branch" className="min-w-0 py-1">
<div className="mb-2 flex items-center gap-2">
<StatusIcon tone="info" shape="circle" size="md"><GitFork size={14} /></StatusIcon>
<span className="text-xs font-semibold text-slate-800">并行处理</span>
<StatusBadge tone="info">{layout.branches.length} 条独立分枝</StatusBadge>
</div>
<div className="grid snap-x snap-mandatory auto-cols-[86%] grid-flow-col gap-2 overflow-x-auto overscroll-x-contain pb-1 sm:snap-none sm:grid-flow-row sm:grid-cols-2 sm:overflow-visible">
{layout.branches.map((branch, index) => {
const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id);
return (
<section key={branch.map((node) => node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5">
<p className="mb-2 text-[11px] font-semibold text-slate-500">分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}</p>
<ExecutionChain nodes={branch} plan={plan} nodeActions={nodeActions} compact />
</section>
);
})}
</div>
</section>
) : null}
{layout.suffix.length > 0 ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-[11px] text-slate-500"><span className="h-px flex-1 bg-slate-200" /><span>分枝汇合</span><span className="h-px flex-1 bg-slate-200" /></div>
<ExecutionFlow plan={subPlan(plan, layout.suffix.map((node) => node.id))} nodeActions={nodeActions} depth={depth + 1} />
</div>
) : null}
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const activeEdges = plan.edges.filter((edge) => {
const source = nodeById.get(edge.from);
return source?.kind !== "decision" || source.selected_edge_id === edge.id;
});
const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id));
const visible = new Set<string>();
const queue = roots.map((node) => node.id);
while (queue.length > 0) {
const current = queue.shift()!;
if (visible.has(current)) continue;
visible.add(current);
for (const edge of activeEdges.filter((item) => item.from === current)) queue.push(edge.to);
}
const ordered = topologicalNodes(plan);
const alternatives = ordered.filter((node) => !visible.has(node.id));
return <div className="space-y-3">
<div className="space-y-2 border-l border-slate-200 pl-3">
{roots.filter((node) => visible.has(node.id)).map((node) => <SemanticPath key={node.id} nodeId={node.id} stopId={null} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={new Set()} />)}
</div>
);
{alternatives.length > 0 ? <Disclosure label={`其他规划路线 ${alternatives.length} 项`} className="surface-control rounded-lg border border-slate-200/70 px-2.5">
<ol className="grid gap-2 border-t border-slate-200/70 py-2">{alternatives.map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact /></li>)}</ol>
</Disclosure> : null}
</div>;
}
function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; compact?: boolean }) {
return <div className="grid gap-0">{nodes.map((node, index) => {
const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined;
return <div key={node.id}>{index > 0 ? <div className="flex min-h-6 items-center gap-2 px-3 text-[10px] text-slate-500"><span className="h-6 w-px bg-slate-300" />{edge?.condition ? <span>{edge.condition}</span> : null}</div> : null}<ExecutionNodeCard node={node} action={nodeActions[node.id]} compact={compact} /></div>;
})}</div>;
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: {
nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[];
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
}) {
if (nodeId === stopId || seen.has(nodeId)) return null;
const node = nodeById.get(nodeId);
if (!node) return null;
const nextSeen = new Set(seen).add(nodeId);
const outgoing = activeEdges.filter((edge) => edge.from === nodeId);
const allRoutes = plan.edges.filter((edge) => edge.from === nodeId);
const join = node.kind === "parallel_split" ? findSemanticJoin(outgoing, activeEdges, nodeById, stopId) : null;
const branchEnd = join?.id ?? stopId;
return <div className="space-y-2">
<ExecutionNodeCard node={node} action={nodeActions[node.id]} />
{node.kind === "decision" ? <div className="rounded-lg bg-blue-50/70 px-3 py-2 text-xs leading-5 text-slate-700">
<span className="font-semibold">路线选择:</span>{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"}
<div className="mt-1 text-slate-500">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
</div> : null}
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-blue-100 bg-blue-50/40 p-2">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-blue-800"><GitFork size={13} aria-hidden="true" />并行处理 · {outgoing.length} 路</p>
<div className="grid gap-2 sm:grid-cols-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 rounded-lg border border-slate-200/70 bg-white p-2">
<p className="mb-1.5 text-[11px] font-semibold text-slate-600">分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}</p>
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} />
</div>)}</div>
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> : null}
</div>;
}
function findSemanticJoin(outgoing: ExecutionEdge[], activeEdges: ExecutionEdge[], nodeById: Map<string, ExecutionNode>, stopId: string | null) {
if (outgoing.length < 2) return null;
const reachable = outgoing.map((edge) => reachableFrom(edge.to, new Map([...nodeById.keys()].map((id) => [id, activeEdges.filter((item) => item.from === id)]))));
for (const node of nodeById.values()) {
if (node.id === stopId) continue;
if (node.kind === "parallel_join" && reachable.every((items) => items.has(node.id))) return node;
}
return null;
}
function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
return <ol aria-label="调查执行步骤" className="grid gap-2">{topologicalNodes(plan).map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} /></li>)}</ol>;
}
function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView {
const included = new Set(nodeIds);
return {
nodes: plan.nodes.filter((node) => included.has(node.id)),
edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to))
};
const titleById = new Map(plan.nodes.map((node) => [node.id, node.title]));
return <div><p className="mb-2 text-[11px] leading-5 text-slate-500">历史流程按原始依赖展示;未标注路线或并行类型。</p><ol className="grid gap-2">{topologicalNodes(plan).map((node) => {
const prerequisites = plan.edges.filter((edge) => edge.to === node.id);
return <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact />{prerequisites.length > 0 ? <p className="px-2 pt-1 text-[11px] leading-5 text-slate-500">前置:{prerequisites.map((edge) => `${titleById.get(edge.from) ?? edge.from}${edge.condition || edge.label ? `(${edge.condition ?? edge.label})` : ""}`).join("、")}</p> : null}</li>;
})}</ol></div>;
}
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
@@ -280,6 +322,7 @@ function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionN
<StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div>
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-blue-700">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
{action ? <div className="mt-2">{action}</div> : null}
</div>
@@ -356,25 +399,70 @@ function EvidenceMeta({ label, value, mono = false }: { label: string; value: st
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-slate-500">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
}
function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onRefresh }: {
export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: {
request: OperationalTask["access_requests"][number];
nodeTitle?: string;
canDecide: boolean;
onDecision?: AgentExecutionPlanPanelProps["onAccessDecision"];
onRefresh: () => void;
}) {
const [selected, setSelected] = useState(request.scopes);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const decide = async (decision: "approved" | "rejected") => {
if (!onDecision) return;
setBusy(true); setError(null);
try {
await onDecision(request.access_request_id, request.version, decision, decision === "approved" ? selected : []);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "审核提交失败");
if (/409|版本|conflict/i.test(String(nextError))) onRefresh();
} finally { setBusy(false); }
};
return <article className="surface-reading rounded-xl border border-amber-200/80 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">数据访问申请</h3><StatusBadge tone={request.status === "approved" ? "success" : request.status === "rejected" ? "danger" : "warning"}>{request.status === "approved" ? "已批准" : request.status === "rejected" ? "已拒绝" : "待审核"}</StatusBadge></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{request.reason}</p>
<p className="mt-1 text-[11px] text-slate-500">关联步骤 {nodeTitle ?? request.node_id} · 申请于 {formatDateTime(request.requested_at)}</p>
<div className="mt-2 grid gap-1">{request.scopes.map((scope) => <label key={scope} className="flex min-h-10 items-center gap-2 text-xs text-slate-700"><input type="checkbox" checked={selected.includes(scope)} disabled={request.status !== "pending" || !canDecide || busy} onChange={(event) => setSelected((current) => event.target.checked ? [...current, scope] : current.filter((item) => item !== scope))} />{dataScopeLabel(scope)}</label>)}</div>
{request.status !== "pending" ? <p className="mt-1 text-xs text-slate-600">{request.status === "approved" ? `已授权:${request.approved_scopes.map(dataScopeLabel).join("、")}` : "未授予数据访问权限"}</p> : null}
{request.status === "pending" && canDecide && onDecision ? <div className="mt-2 flex justify-end gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void decide("rejected")}>拒绝</Button><Button size="sm" disabled={busy || selected.length === 0} onClick={() => void decide("approved")}>批准所选范围</Button></div> : null}
{request.status === "pending" && !canDecide ? <p className="mt-2 text-xs text-slate-500">等待具有该数据源读取权限的项目成员审核。</p> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
</article>;
}
export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUpdate, onConfirmDispatch, onReconcile, onRefresh }: {
task: OperationalTask;
action: OperationalTaskAction;
onDecision: AgentExecutionPlanPanelProps["onDecision"];
onExecute: AgentExecutionPlanPanelProps["onExecute"];
onDraftUpdate?: AgentExecutionPlanPanelProps["onDraftUpdate"];
onConfirmDispatch?: AgentExecutionPlanPanelProps["onConfirmDispatch"];
onReconcile: AgentExecutionPlanPanelProps["onReconcile"];
onRefresh: () => void;
}) {
const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | null>(null);
const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | "save" | "confirm" | null>(null);
const [error, setError] = useState<string | null>(null);
const [stale, setStale] = useState(false);
const [rejectComment, setRejectComment] = useState("");
const [editing, setEditing] = useState(false);
const [savedVersion, setSavedVersion] = useState<number | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>(action.payload);
const command = action.kind === "issue_command";
const canDecide = command ? task.permissions.can_decide_commands : task.permissions.can_decide_work_orders;
const canExecute = command ? task.permissions.can_execute_commands : task.permissions.can_execute_work_orders;
const run = async (kind: "approve" | "reject" | "execute" | "reconcile") => {
const run = async (kind: "approve" | "reject" | "execute" | "reconcile" | "save" | "confirm") => {
setSubmitting(kind); setError(null);
try {
if (kind === "execute") await onExecute(action.action_id, action.version);
if (kind === "save") {
if (!onDraftUpdate) throw new Error("工单草稿编辑不可用");
await onDraftUpdate(action.action_id, action.version, draft);
setSavedVersion(action.version);
setEditing(false);
} else if (kind === "confirm") {
if (!onConfirmDispatch) throw new Error("工单确认下发不可用");
await onConfirmDispatch(action.action_id, action.version);
} else if (kind === "execute") await onExecute(action.action_id, action.version);
else if (kind === "reconcile") await onReconcile(action.action_id, action.version);
else {
if (kind === "reject" && !rejectComment.trim()) {
@@ -397,6 +485,15 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-slate-500">原因:</span>{action.reason}</p>
<ActionPayloadSummary payload={action.payload} />
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2.5">
<Input aria-label="工单标题" value={String(draft.title ?? "")} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
<Input aria-label="工单说明" value={String(draft.description ?? "")} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
<div className="grid grid-cols-2 gap-2"><select aria-label="工单类型" className="min-h-10 rounded-lg border border-slate-200 bg-white px-2 text-xs" value={String(draft.work_order_type ?? "general")} onChange={(event) => setDraft({ ...draft, work_order_type: event.target.value })}><option value="general">一般工单</option><option value="water_quality_complaint">水质投诉</option><option value="burst_report">爆管上报</option></select><select aria-label="工单优先级" className="min-h-10 rounded-lg border border-slate-200 bg-white px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select></div>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(action.payload); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null}
{action.evidence_refs.length > 0 ? (
<div className="mt-2 rounded-lg bg-blue-50/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
<p className="font-semibold text-blue-700">确认依据</p>
@@ -409,6 +506,7 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
</div>
) : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-700">{action.execution_detail}</p> : null}
<Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-slate-200/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-slate-200/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure>
<p className="mt-2 text-[11px] leading-5 text-slate-500">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
{error ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{error} 当前状态未按成功处理。</p> : null}
@@ -416,9 +514,9 @@ function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onR
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button><Button size="sm" disabled={submitting !== null} onClick={() => void run("approve")}>{submitting === "approve" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : "批准创建工单"}</Button></> : null}
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(action.payload); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并下发工单" : "批准创建工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建工单"}</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown", "failed", "unsupported"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div>
</div>
</div>
@@ -485,41 +583,6 @@ function externalStatusLabel(value: string) {
return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record<string, string>)[value] ?? value;
}
function createBranchLayout(plan: ExecutionPlanView) {
const ordered = topologicalNodes(plan);
if (ordered.length !== plan.nodes.length) return null;
const outgoing = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.from === node.id)]));
const incoming = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.to === node.id)]));
const fork = ordered.find((node) => (outgoing.get(node.id)?.length ?? 0) > 1);
if (!fork) return { prefix: ordered, branches: [] as ExecutionNode[][], suffix: [] as ExecutionNode[] };
const starts = outgoing.get(fork.id)!.map((edge) => edge.to);
const descendants = starts.map((start) => reachableFrom(start, outgoing));
const join = ordered.find((node) => node.id !== fork.id && (incoming.get(node.id)?.length ?? 0) > 1 && descendants.every((set) => set.has(node.id)));
if (!join) return null;
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const branches: ExecutionNode[][] = [];
for (const start of starts) {
const branch: ExecutionNode[] = [];
let current = start;
const seen = new Set<string>();
while (current !== join.id && !seen.has(current)) {
seen.add(current);
const node = nodeById.get(current);
if (!node) return null;
branch.push(node);
const nextEdges = outgoing.get(current) ?? [];
if (nextEdges.length !== 1) return null;
current = nextEdges[0].to;
}
if (current !== join.id || branch.length === 0) return null;
branches.push(branch);
}
const branchIds = new Set(branches.flatMap((branch) => branch.map((node) => node.id)));
const forkIndex = ordered.findIndex((node) => node.id === fork.id);
const joinIndex = ordered.findIndex((node) => node.id === join.id);
return { prefix: ordered.slice(0, forkIndex + 1), branches, suffix: ordered.slice(joinIndex).filter((node) => !branchIds.has(node.id)) };
}
function reachableFrom(start: string, outgoing: Map<string, ExecutionEdge[]>) {
const seen = new Set<string>();
const stack = [start];
@@ -546,9 +609,20 @@ function executionNodePresentation(node: ExecutionNode) {
}
function taskTypeLabel(type: OperationalTask["task_type"]) { return { scheduled_flow: "定时流程", external_work_order: "外部工单", event_investigation: "事件调查" }[type]; }
function dataScopeLabel(scope: string) { return ({
workbench_runtime: "工作台运行数据", scada_devices: "SCADA 设备", scada_readings: "SCADA 监测读数",
peripheral_points: "外围监测点", peripheral_work_orders: "外围工单",
peripheral_observations: "外围监测记录", peripheral_test_data: "外围测试数据"
} as Record<string, string>)[scope] ?? scope; }
function taskStatusLabel(status: OperationalTask["status"]) { return { queued: "排队中", running: "处理中", waiting_human: "待人工", completed: "已完成", failed: "失败", cancelled: "已取消" }[status]; }
function evidenceKindLabel(kind: OperationalTaskEvidence["kind"]) { return { system_data: "系统数据", external_feedback: "外部反馈", human_input: "人工提供", synthetic: "合成演练" }[kind]; }
function formatDateTime(value: string) { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(parsed); }
function toLocalDateTime(value: unknown) {
if (typeof value !== "string") return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
function approvalLabel(status: OperationalTaskAction["approval_status"]) { return { pending: "待确认", approved: "已批准", rejected: "已拒绝" }[status]; }
function approvalTone(status: OperationalTaskAction["approval_status"]): StatusTone { return status === "approved" ? "success" : status === "rejected" ? "danger" : "warning"; }
function executionLabel(status: OperationalTaskAction["execution_status"]) { return { not_submitted: "未提交", submitted: "已提交", succeeded: "执行成功", failed: "执行失败", unsupported: "未接执行器", unknown: "结果未知" }[status]; }
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import { ConditionDetailPanel, WorkOrderActivityPanel } from "./scheduled-condition-detail-panel";
import { ConditionDetailPanel, WorkOrderReportPanel } from "./scheduled-condition-detail-panel";
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
@@ -34,11 +34,12 @@ it("does not infer water-quality progress or conversation steps from elapsed tim
describe("scheduled condition backend boundaries", () => {
afterEach(cleanup);
it("shows a flat work order with its recorded activity", () => {
it("shows a work order report with explicit feedback and no activity log", () => {
const workOrder: ScheduledWorkOrderItem = {
id: "demo-work-order",
kind: "work_order",
code: "WO-DEMO",
externalStatus: "open",
scheduledAt: "2026-09-23T10:00:00Z",
title: "演示预约维修",
summary: "验证中文时间标签",
@@ -52,13 +53,24 @@ describe("scheduled condition backend boundaries", () => {
location: "测试点",
assignee: "维修班组",
feedbackRequirements: ["回填处理结果"],
activity: [{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗",
kind: "created", title: "工单创建", detail: "确认预约窗口。" }]
activity: [
{ sequence: 1, occurredAt: "2026-09-23T09:00:00Z", actor: "调度岗",
kind: "created", title: "工单创建", detail: "确认预约窗口。" },
{ sequence: 2, occurredAt: "2026-09-23T11:00:00Z", actor: "维修班组",
kind: "feedback", title: "现场反馈", detail: "阀井检修已完成。" }
]
};
render(<WorkOrderActivityPanel condition={workOrder} />);
expect(screen.getByText("工单创建")).toBeVisible();
render(<WorkOrderReportPanel condition={workOrder} />);
expect(screen.getByText("工单报告")).toBeVisible();
expect(screen.getByText("工单编号:WO-DEMO")).toBeVisible();
expect(screen.getByText("阀井检修已完成。")).toBeVisible();
expect(screen.getByText("回填处理结果")).toBeVisible();
expect(screen.getByText("工单已分配,暂无独立的接单确认记录。")).toBeVisible();
expect(screen.getByRole("list", { name: "工单进度" }).querySelectorAll("li")).toHaveLength(3);
expect(screen.queryByText("工单创建")).not.toBeInTheDocument();
expect(screen.queryByText("活动记录")).not.toBeInTheDocument();
expect(screen.queryByText("关键证据")).not.toBeInTheDocument();
expect(screen.queryByText("子任务")).not.toBeInTheDocument();
});
@@ -69,6 +69,11 @@ const riskTones = {
critical: "danger"
} satisfies Record<ScheduledConditionRiskLevel, StatusTone>;
const externalWorkOrderStatusLabels = {
draft: "待派发", open: "已分配", in_progress: "执行中",
awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消"
} as const;
type IncidentOperation = {
target: string;
elementType: string;
@@ -109,6 +114,9 @@ type ConditionDetailPanelProps = {
onContinueConversation: () => void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
@@ -122,18 +130,25 @@ export function StatusPill({
className?: string;
}) {
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const externalStatus = condition.kind === "work_order"
? condition.externalStatus
: condition.operationalTask?.task_type === "external_work_order"
? condition.operationalTask.work_order_snapshot?.status
: undefined;
const badgeStatus = externalStatus === "awaiting_reply" || externalStatus === "cancelled"
? externalStatus : condition.status;
return (
<StatusBadge
tone={
isAgentEvent ? "neutral" : getStatusTone(condition.status)
isAgentEvent ? "neutral" : getStatusTone(badgeStatus)
}
activity={
isAgentEvent ? "static" : getStatusActivity(condition.status)
isAgentEvent ? "static" : getStatusActivity(badgeStatus)
}
className={className}
>
{isAgentEvent ? "事件调查" : statusLabels[condition.status]}
{isAgentEvent ? "事件调查" : externalStatus ? externalWorkOrderStatusLabels[externalStatus] : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -145,6 +160,9 @@ export function ConditionDetailPanel({
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
@@ -157,7 +175,6 @@ export function ConditionDetailPanel({
condition.kind === "work_order" ? (
<WorkOrderDetailPanel
condition={condition}
dataSource={condition.dataSource ?? workOrderDataSource}
/>
) : (
<ConditionRecordDetailPanel
@@ -167,6 +184,9 @@ export function ConditionDetailPanel({
onContinueConversation={onContinueConversation}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
onTaskAccessDecision={onTaskAccessDecision}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
@@ -196,6 +216,9 @@ function ConditionRecordDetailPanel({
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
@@ -206,6 +229,9 @@ function ConditionRecordDetailPanel({
onContinueConversation: () => void;
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
onTaskAccessDecision?: ConditionDetailPanelProps["onTaskAccessDecision"];
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
onTaskRefresh?: () => void;
@@ -239,6 +265,7 @@ function ConditionRecordDetailPanel({
icon={header.icon}
iconClassName={header.iconClassName}
iconSpin={header.iconSpin}
hideDetail={condition.operationalTask?.task_type === "external_work_order"}
/>
<DetailDecisionBody>
<AnimatePresence initial={false} mode="wait">
@@ -250,16 +277,17 @@ function ConditionRecordDetailPanel({
exit={{ opacity: 0, y: -8, scale: 0.996 }}
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
>
{condition.operationalTask ? (
{condition.operationalTask?.task_type === "external_work_order" ? (
externalWorkOrder ? (
<WorkOrderReportPanel condition={externalWorkOrder} />
) : (
<DetailSectionCard>
<SectionTitle icon={ClipboardList} title="工单报告" />
<p className="mt-2 text-xs leading-5 text-slate-500">尚未同步到工单详情,请刷新工况任务后查看。</p>
</DetailSectionCard>
)
) : condition.operationalTask ? (
<>
{externalWorkOrder ? <WorkOrderActivityPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot ? (
<DetailSectionCard>
<SectionTitle icon={ListChecks} title="预约过程" />
<p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单快照,不根据任务状态推测现场进展。</p>
</DetailSectionCard>
) : null}
<AgentExecutionPlanPanel
task={condition.operationalTask}
onContinue={onContinueConversation}
@@ -271,6 +299,18 @@ function ConditionRecordDetailPanel({
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
return onTaskExecute(condition.operationalTask!.task_id, actionId, version);
}}
onDraftUpdate={(actionId, version, payload) => {
if (!onTaskDraftUpdate) return Promise.reject(new Error("当前未配置工单草稿编辑接口。"));
return onTaskDraftUpdate(condition.operationalTask!.task_id, actionId, version, payload);
}}
onConfirmDispatch={(actionId, version) => {
if (!onTaskConfirmDispatch) return Promise.reject(new Error("当前未配置工单下发接口。"));
return onTaskConfirmDispatch(condition.operationalTask!.task_id, actionId, version);
}}
onAccessDecision={(requestId, version, decision, scopes) => {
if (!onTaskAccessDecision) return Promise.reject(new Error("当前未配置数据授权接口。"));
return onTaskAccessDecision(condition.operationalTask!.task_id, requestId, version, decision, scopes);
}}
onReconcile={(actionId, version) => {
if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。"));
return onTaskReconcile(condition.operationalTask!.task_id, actionId, version);
@@ -439,13 +479,7 @@ function SourceBoundaryNotice({
);
}
function WorkOrderDetailPanel({
condition,
dataSource
}: {
condition: ScheduledWorkOrderItem;
dataSource: WorkbenchWorkOrderDataSource;
}) {
function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
return (
<DetailScroll>
<DetailShell>
@@ -453,23 +487,10 @@ function WorkOrderDetailPanel({
condition={condition}
icon={ClipboardList}
iconClassName="bg-blue-50 text-blue-700"
hideDetail
/>
<DetailDecisionBody>
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend"
? "工单状态、负责人、回执要求与活动记录均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
/>
<WorkOrderActivityPanel condition={condition} />
<DecisionActionStrip
icon={CalendarClock}
title="工单跟踪"
value={condition.recommendation ?? "按工单活动记录跟踪现场处置。"}
actionLabel={condition.status === "awaiting_reply" ? "待复令" : "跟踪工单"}
actionDisabled
actionTitle="工单流程由调度和现场班组流转"
/>
<WorkOrderReportPanel condition={condition} />
</DetailDecisionBody>
</DetailShell>
</DetailScroll>
@@ -562,13 +583,15 @@ type DetailHeaderProps = {
icon: typeof FileText;
iconClassName?: string;
iconSpin?: boolean;
hideDetail?: boolean;
};
function DetailHeader({
condition,
icon: Icon,
iconClassName,
iconSpin = false
iconSpin = false,
hideDetail = false
}: DetailHeaderProps) {
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks";
@@ -593,7 +616,7 @@ function DetailHeader({
) : null}
</div>
<h3 className="mt-1 text-base font-semibold leading-5 text-slate-950">{condition.title}</h3>
{condition.detail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
{condition.detail && !hideDetail ? <p className="mt-1.5 text-xs leading-5 text-slate-700">{condition.detail}</p> : null}
</div>
</div>
@@ -1271,51 +1294,154 @@ function TaskExecutionStepRow({
);
}
export function WorkOrderActivityPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
export function WorkOrderReportPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const latestFeedback = [...condition.activity]
.filter((entry) => entry.kind === "feedback")
.sort((a, b) => b.sequence - a.sequence)[0];
const progress = getWorkOrderProgress(condition);
const steps = getWorkOrderExecutionSteps(condition, latestFeedback !== undefined);
return (
<DetailSectionCard>
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={ClipboardList} title="预约工单" />
<StatusBadge tone={getStatusTone(condition.status)}>{statusLabels[condition.status]}</StatusBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
<ReportMetaTile label="来源" value={condition.source} />
<ReportMetaTile label="负责人" value={condition.assignee ?? "待分配"} />
<ReportMetaTile label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<ReportMetaTile label="预约时间" value={formatTime(condition.scheduledAt)} />
</div>
{condition.location ? <p className="mt-3 text-xs text-slate-600">位置:{condition.location}</p> : null}
{condition.feedbackRequirements.length ? (
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">回执要求</h4>
<ul className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => <li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700"><CheckCircle2 className="mt-0.5 shrink-0 text-blue-600" size={14} />{item}</li>)}
</ul>
</section>
) : null}
<section className="mt-3 border-t border-slate-200/70 pt-3">
<h4 className="text-xs font-semibold text-slate-700">活动记录</h4>
{condition.activity.length ? (
<ol className="mt-2 border-l border-blue-200 pl-4">
{[...condition.activity].sort((a, b) => b.sequence - a.sequence).map((entry) => (
<li key={entry.sequence} className="relative pb-4 last:pb-0 before:absolute before:-left-[21px] before:top-1 before:h-2.5 before:w-2.5 before:rounded-full before:bg-blue-500">
<div className="flex flex-wrap items-baseline justify-between gap-1"><span className="text-xs font-semibold text-slate-800">{entry.title}</span><time className="text-[10px] text-slate-500">{formatTime(entry.occurredAt)}</time></div>
<p className="mt-1 text-xs leading-5 text-slate-600">{entry.detail}</p>
<p className="mt-1 text-[10px] text-slate-400">{entry.actor}</p>
</li>
))}
<div className="space-y-2.5">
<DetailSectionCard className="overflow-hidden p-0">
<div className="border-b border-slate-200/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-[10px] font-semibold text-slate-400">工单报告</p>
<SectionTitle icon={ClipboardList} title="任务执行" />
</div>
<StatusBadge tone={getStatusTone(condition.status)}>{progress.label}</StatusBadge>
</div>
<p className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{condition.summary}</p>
<p className="mt-1 break-all text-[11px] text-slate-500">工单编号:{condition.code}</p>
<section aria-label="派单信息" className="mt-3">
<h4 className="mb-1.5 text-[11px] font-semibold text-slate-500">派单信息</h4>
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<WorkOrderField label="来源" value={condition.source} />
<WorkOrderField label="位置" value={condition.location ?? "未填写"} />
<WorkOrderField label="派发人" value={condition.dispatcher === "外围系统同步" ? "未记录" : condition.dispatcher} />
<WorkOrderField label="执行班组" value={condition.assignee ?? "待分配"} />
<WorkOrderField label="优先级" value={condition.priority === "urgent" ? "紧急" : "普通"} />
<WorkOrderField label="预约时间" value={formatWorkOrderScheduledAt(condition)} />
</dl>
</section>
</div>
{progress.cancelled ? (
<p className="px-3 py-3 text-xs text-slate-600">工单已取消,后续办理步骤不适用。</p>
) : (
<ol aria-label="工单进度" className="grid gap-2 px-3 py-3">
{steps.map((step, index) => <TaskExecutionStepRow key={step.id} step={step} isLast={index === steps.length - 1} />)}
</ol>
) : <p className="mt-2 text-xs text-slate-500">暂无活动记录;不根据工单状态推测处置过程。</p>}
</section>
</DetailSectionCard>
)}
</DetailSectionCard>
<DetailSectionCard className="px-3 py-3">
<section aria-label="回执信息">
<SectionTitle icon={ListChecks} title="复令要求" />
{condition.feedbackRequirements.length ? (
<ol className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => (
<li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700">
<span className="grid size-5 shrink-0 place-items-center rounded-full border border-blue-100 bg-white text-[11px] font-semibold text-blue-700">{index + 1}</span>
<span className="min-w-0 break-words">{item}</span>
</li>
))}
</ol>
) : <p className="mt-2 text-xs text-slate-500">未填写回执要求</p>}
<div className="mt-3 border-t border-slate-200/70 pt-3">
<p className="text-[11px] font-semibold text-slate-500">现场反馈</p>
{latestFeedback ? (
<div className="mt-1 rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-700">
<p className="whitespace-pre-wrap break-words">{latestFeedback.detail}</p>
<p className="mt-1 text-[11px] text-slate-500">{latestFeedback.actor} · {formatWorkOrderDateTime(latestFeedback.occurredAt)}</p>
</div>
) : <p className="mt-1 text-xs text-slate-500">暂无现场反馈</p>}
</div>
</section>
</DetailSectionCard>
<DetailSectionCard className="px-3 py-3">
<SectionTitle icon={CalendarClock} title={`当前状态:${progress.label}`} />
<p className="mt-1.5 text-xs leading-5 text-slate-700">{progress.description}</p>
{condition.recommendation ? <p className="mt-1 text-xs leading-5 text-slate-700">{condition.recommendation}</p> : null}
</DetailSectionCard>
</div>
);
}
function WorkOrderField({ label, value }: { label: string; value: string }) {
return <div className={cn(REPORT_META_TILE_CLASS_NAME, "min-w-0")}><dt className="text-[11px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-xs font-medium leading-5 text-slate-800">{value}</dd></div>;
}
function getWorkOrderExecutionSteps(condition: ScheduledWorkOrderItem, hasFeedback: boolean): ExecutionStep[] {
const status = getWorkOrderSourceStatus(condition);
return [
{
id: "dispatch", title: "派发", status: status === "open" ? "done" : "pending",
statusLabel: status === "draft" ? "待派发" : status === "open" ? "已分配" : "未单独记录",
description: status === "open" ? "外围工单已分配,接单确认与实际派发时间未单独记录。" : "派发人与派发时间以来源工单的明确记录为准。",
meta: [{ label: "责任人", value: condition.dispatcher === "外围系统同步" ? "未记录" : condition.dispatcher },
{ label: "时间", value: "未记录" }]
},
{
id: "execution", title: "执行中", status: status === "in_progress" ? "current" : "pending",
statusLabel: status === "in_progress" ? "执行中" : status === "draft" || status === "open" ? "待执行" : "未单独记录",
description: hasFeedback ? "已收到现场反馈,具体内容见下方。" : "尚无现场处理回填。",
meta: [{ label: "责任人", value: condition.assignee ?? "待分配" },
{ label: condition.durationMinutes ? "计划窗口" : "预约", value: condition.durationMinutes
? formatTimeRange(condition.scheduledAt, condition.durationMinutes)
: formatWorkOrderScheduledAt(condition) }]
},
{
id: "reply", title: "待复令", status: status === "awaiting_reply" ? "confirmation" : "pending",
statusLabel: status === "awaiting_reply" ? "待复令" : status === "completed" ? "未单独记录" : "待处理",
description: condition.feedbackRequirements.length ? "按下方要求提交处置结果,由调度确认。" : "等待现场回执与调度确认。",
meta: [{ label: "责任人", value: condition.assignee ?? "待分配" },
{ label: "时间", value: "未记录" }]
}
];
}
function getWorkOrderProgress(condition: ScheduledWorkOrderItem): {
label: string;
cancelled: boolean;
description: string;
} {
const status = getWorkOrderSourceStatus(condition);
switch (status) {
case "draft": return { label: "待派发", cancelled: false, description: "当前仍为草稿,尚未进入已分配状态。" };
case "open": return { label: "已分配", cancelled: false, description: "工单已分配,暂无独立的接单确认记录。" };
case "in_progress": return { label: "执行中", cancelled: false, description: "当前处于现场执行状态。" };
case "awaiting_reply": return { label: "待复令", cancelled: false, description: "当前等待现场回填和调度确认。" };
case "completed": return { label: "已完成", cancelled: false, description: "工单状态为已完成;具体处理结果以现场回填为准。" };
case "cancelled": return { label: "已取消", cancelled: true, description: "外围工单状态为已取消。" };
}
}
function getWorkOrderSourceStatus(condition: ScheduledWorkOrderItem): NonNullable<ScheduledWorkOrderItem["externalStatus"]> {
return condition.externalStatus ?? ({
pending: "draft", running: "in_progress", awaiting_reply: "awaiting_reply",
completed: "completed", cancelled: "cancelled"
} as const)[condition.status];
}
function formatWorkOrderDateTime(value: string) {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? value : new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
}).format(timestamp);
}
function formatWorkOrderScheduledAt(condition: ScheduledWorkOrderItem) {
return condition.workOrderScheduledAt === null
? "未安排" : formatWorkOrderDateTime(condition.workOrderScheduledAt ?? condition.scheduledAt);
}
function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem {
return {
id: snapshot.work_order_id,
kind: "work_order",
code: snapshot.scenario_code ?? snapshot.work_order_id,
externalStatus: snapshot.status,
workOrderScheduledAt: snapshot.scheduled_at,
scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at,
title: snapshot.title,
summary: snapshot.summary,
@@ -1,6 +1,5 @@
import {
CalendarClock,
CalendarPlus,
ChevronDown,
ChevronRight,
ClipboardList,
@@ -30,11 +29,6 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
WORKBENCH_TASK_CATEGORIES,
@@ -69,14 +63,6 @@ export type ScheduledConditionFeedPresentation =
| "desktop-floating"
| "mobile-sheet";
export type ScheduleCreateRequest = {
scheduleKey: string;
title: string;
summary: string;
intervalSeconds: number;
nextRunAt: string;
};
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
@@ -99,11 +85,13 @@ type ScheduledConditionFeedProps = {
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
onTaskConfirmDispatch?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskAccessDecision?: (taskId: string, accessRequestId: string, expectedVersion: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onRefresh?: () => void | Promise<void>;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
};
type ScheduledConditionFeedFocusRequest = {
@@ -133,11 +121,13 @@ export function ScheduledConditionFeed({
onOpenTaskCollaboration,
onTaskDecision,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
onTaskAccessDecision,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onRefresh,
onCreateSchedule
onRefresh
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
@@ -147,15 +137,6 @@ export function ScheduledConditionFeed({
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleTitle, setScheduleTitle] = useState("");
const [scheduleKey, setScheduleKey] = useState("scada-diagnosis");
const [scheduleSummary, setScheduleSummary] = useState("");
const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15");
const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15");
const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000)));
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const refreshingRef = useRef(false);
const mobileSheet = presentation === "mobile-sheet";
@@ -300,7 +281,6 @@ export function ScheduledConditionFeed({
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
setTaskCategory(nextCategory);
if (nextCategory !== "scheduled") setScheduleOpen(false);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(
@@ -308,34 +288,6 @@ export function ScheduledConditionFeed({
);
}
function handleToggleSchedule() {
if (!scheduleOpen) {
setTaskCategory("scheduled");
updateExpanded(true);
}
setScheduleOpen((current) => !current);
}
async function handleCreateSchedule() {
const title = scheduleTitle.trim();
const summary = scheduleSummary.trim();
const key = scheduleKey.trim();
const intervalMinutes = Number(scheduleIntervalMinutes);
const nextRunAt = new Date(scheduleNextRunAt);
if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; }
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; }
if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; }
if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; }
setScheduleSubmitting(true); setScheduleError(null);
try {
await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() });
setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary("");
showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" });
} catch (error) {
setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。");
} finally { setScheduleSubmitting(false); }
}
async function handleRefresh() {
if (!onRefresh || refreshingRef.current) return;
refreshingRef.current = true;
@@ -474,17 +426,6 @@ export function ScheduledConditionFeed({
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
{onCreateSchedule ? (
<button
type="button"
aria-label="配置定时流程"
aria-expanded={scheduleOpen}
onClick={handleToggleSchedule}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<CalendarPlus size={17} aria-hidden="true" />
</button>
) : null}
{onRefresh ? (
<button
type="button"
@@ -542,26 +483,6 @@ export function ScheduledConditionFeed({
/>
) : null}
{scheduleOpen ? (
<section aria-label="配置定时流程" className={cn("mt-2 p-3", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<label className="block text-xs font-medium text-slate-600">定时流程名称<Input className="mt-1 h-9" placeholder="例如夜间低压巡检" value={scheduleTitle} onChange={(event) => setScheduleTitle(event.target.value)} /></label>
<div className="mt-2"><FieldSelect label="执行流程" value={scheduleKey} onValueChange={setScheduleKey} options={[{ value: "scada-diagnosis", label: "SCADA 数据诊断" }, { value: "smart-dispatch", label: "管网智能调度" }, { value: "network-simulation", label: "管网定时模拟" }, { value: "pump-energy", label: "泵站能耗检查" }]} /></div>
<label className="mt-2 block text-xs font-medium text-slate-600">任务说明<Textarea className="mt-1 min-h-16" placeholder="说明本流程核查什么以及预期输出" value={scheduleSummary} onChange={(event) => setScheduleSummary(event.target.value)} /></label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<FieldSelect label="执行周期" value={scheduleIntervalPreset} onValueChange={(value) => { setScheduleIntervalPreset(value); if (value !== "custom") setScheduleIntervalMinutes(value); }} options={[{ value: "5", label: "每 5 分钟" }, { value: "15", label: "每 15 分钟" }, { value: "30", label: "每 30 分钟" }, { value: "60", label: "每 60 分钟" }, { value: "custom", label: "自定义周期" }]} />
{scheduleIntervalPreset === "custom" ? <label className="mt-2 block text-xs font-medium text-slate-600">自定义分钟数<Input className="mt-1 h-9" inputMode="numeric" value={scheduleIntervalMinutes} onChange={(event) => setScheduleIntervalMinutes(event.target.value.replace(/\D/g, ""))} /></label> : null}
</div>
<DateTimeField label="下次执行时间" value={scheduleNextRunAt} onChange={setScheduleNextRunAt} />
</div>
{scheduleError ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{scheduleError}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" disabled={scheduleSubmitting} onClick={() => setScheduleOpen(false)}>取消</Button>
<Button type="button" size="sm" disabled={scheduleSubmitting} onClick={() => void handleCreateSchedule()}>{scheduleSubmitting ? "创建中" : "创建定时流程"}</Button>
</div>
</section>
) : null}
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
@@ -595,6 +516,9 @@ export function ScheduledConditionFeed({
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
onTaskAccessDecision={onTaskAccessDecision}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
@@ -690,11 +614,6 @@ function TaskCategorySwitch({
);
}
function formatLocalDateTimeInput(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
@@ -0,0 +1,33 @@
import { ShieldCheck } from "lucide-react";
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
type TaskConversationReviewProps = {
task: OperationalTask;
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
onRefresh: () => void;
};
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
const pendingAccess = task.access_requests.filter((request) => request.status === "pending");
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
if (pendingAccess.length === 0 && pendingActions.length === 0) return null;
return <section aria-label="当前任务审核" className="surface-reading space-y-2 rounded-xl border border-blue-200/80 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={14} aria-hidden="true" />当前任务审核 · {task.title}</div>
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
canDecide={task.permissions.can_decide_access}
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
onRefresh={onRefresh} />)}
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
</section>;
}