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
@@ -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]; }