Files
next-tjwater-frontend/src/features/workbench/components/agent-execution-plan-panel.tsx
T

630 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, type ReactNode } from "react";
import {
AlertTriangle,
CheckCircle2,
Clock3,
FileText,
GitFork,
Loader2,
MessageSquare,
RefreshCw,
Route,
ShieldCheck,
UserCheck
} from "lucide-react";
import type { AgentExecutionPlan } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input";
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskDecision,
OperationalTaskEvidence,
OperationalTaskPlan,
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
type ExecutionNode = {
id: string;
title: string;
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;
condition?: string | null;
};
export type ExecutionPlanView = {
nodes: ExecutionNode[];
edges: ExecutionEdge[];
};
type AgentExecutionPlanPanelProps = {
task: OperationalTask;
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;
};
export function AgentExecutionPlanPanel({
task,
onContinue,
onDecision,
onExecute,
onDraftUpdate,
onConfirmDispatch,
onAccessDecision,
onReconcile,
onLoadEvidenceSnapshot,
onRefresh
}: AgentExecutionPlanPanelProps) {
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation;
const planState = planGeneration == null
? "historical"
: planGeneration === task.execution_generation
? "current"
: "previous";
const reportGeneration = task.latest_report?.execution_generation;
const reportState = reportGeneration == null
? "historical"
: reportGeneration === task.execution_generation
? "current"
: "previous";
const completed = plan?.nodes.filter((node) => node.status === "completed").length ?? 0;
const active = plan?.nodes.filter((node) => node.status === "running").length ?? 0;
const waiting = plan?.nodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length ?? 0;
const externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback");
const taskActive = task.status === "queued" || task.status === "running";
const planSummary = !plan
? "任务已持久化,等待 Agent 发布首版处理计划。"
: planState === "current"
? `已完成 ${completed} 项 · 执行中 ${active} 项 · 待人工或资料 ${waiting} 项`
: planState === "previous"
? taskActive
? "上一轮流程,本轮规划中"
: `上一轮流程,当前任务${taskStatusLabel(task.status)}`
: "历史流程";
const reportLabel = reportState === "current"
? "当前结论"
: reportState === "previous"
? taskActive
? "上一轮报告,本轮分析中"
: `上一轮报告,当前任务${taskStatusLabel(task.status)}`
: "历史报告";
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} 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));
return (
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
<section className="surface-reading overflow-hidden rounded-xl border border-slate-200/70">
<div className="space-y-2 border-b border-slate-200/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800">
<Route size={13} aria-hidden="true" />
{task.task_type === "external_work_order" ? "外部工单进度" : "调查处理流程"}
</h3>
<StatusBadge tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? "success" : "info"}>
{taskStatusLabel(task.status)}
</StatusBadge>
</div>
<p className="text-xs leading-5 text-slate-600" role="status">
{planSummary}
</p>
<p className="text-[11px] leading-5 text-slate-500">
{taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)}
</p>
</div>
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
<ExternalProgress evidence={externalEvidence} />
) : plan ? (
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
) : (
<p className="p-3 text-xs leading-5 text-slate-500">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
)}
</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>
<p className="mt-1 text-[11px] leading-5 text-slate-500">结论与证据独立呈现,不展示模型推理或节点执行日志。</p>
</div>
{report ? (
<>
<div className={cn(
"rounded-lg px-3 py-2.5",
reportState === "current" ? "bg-blue-50/70" : "bg-amber-50/80"
)}>
<p className={cn(
"text-[11px] font-semibold",
reportState === "current" ? "text-blue-700" : "text-amber-800"
)}>
{reportLabel}
</p>
<p className="mt-1 text-xs leading-6 text-slate-800">{report.current_conclusion}</p>
</div>
{report.recommendations.length > 0 ? (
<ReportList title="处置建议" items={report.recommendations} tone="blue" />
) : null}
{report.pending_items.length > 0 ? (
<ReportList title="待确认事项" items={report.pending_items} tone="amber" />
) : null}
<p className="border-t border-slate-200/70 pt-2 text-[11px] leading-5 text-slate-500">结论边界:{report.conclusion_boundary}</p>
</>
) : (
<p className="text-xs leading-6 text-slate-600">尚未形成任务报告。</p>
)}
</section>
<EvidenceSection evidence={task.evidence} referencedIds={report?.evidence_refs ?? []} onLoadSnapshot={onLoadEvidenceSnapshot} />
{unlinkedActions.length > 0 ? (
<section aria-label="待人工确认动作" className="space-y-2.5">
{unlinkedActions.map((action) => (
<HumanActionCard
key={action.action_id}
task={task}
action={action}
onDecision={onDecision}
onExecute={onExecute}
onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch}
onReconcile={onReconcile}
onRefresh={onRefresh}
/>
))}
</section>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onContinue}>
<MessageSquare size={14} aria-hidden="true" />Agent 协作
</Button>
</div>
</section>
);
}
function ReportList({ title, items, tone }: { title: string; items: string[]; tone: "blue" | "amber" }) {
return (
<div>
<p className="text-[11px] font-semibold text-slate-600">{title}</p>
<ul className="mt-1.5 grid gap-1.5">
{items.map((item) => (
<li key={item} className="grid grid-cols-[6px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className={cn("mt-2 h-1.5 w-1.5 rounded-full", tone === "blue" ? "bg-blue-500" : "bg-amber-400")} aria-hidden="true" />
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
export function ExecutionPlanSteps({ plan, nodeActions = {} }: { plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan; nodeActions?: Record<string, ReactNode> }) {
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 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 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> }) {
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 }) {
const { tone, Icon, label } = executionNodePresentation(node);
return (
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-slate-200/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}>
<div className="flex min-w-0 items-start gap-2">
<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>
</div>
</article>
);
}
function ExternalProgress({ evidence }: { evidence: OperationalTaskEvidence[] }) {
const ordered = [...evidence].sort((left, right) => Date.parse(left.observed_at) - Date.parse(right.observed_at));
return (
<ol className="grid gap-2 p-3">
{ordered.map((item, index) => (
<li key={item.evidence_id} className="grid grid-cols-[28px_1fr] gap-2">
<span className="relative flex justify-center">
{index < ordered.length - 1 ? <span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" /> : null}
<StatusIcon tone="info" shape="circle" size="md" className="relative z-10 ring-4 ring-white"><Clock3 size={14} /></StatusIcon>
</span>
<article className="surface-reading rounded-xl border border-slate-200/70 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold text-slate-900">{item.source_label}</span><span className="font-mono text-[11px] text-slate-500">{formatDateTime(item.observed_at)}</span></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{item.summary}</p>
</article>
</li>
))}
</ol>
);
}
function EvidenceSection({ evidence, referencedIds, onLoadSnapshot }: { evidence: OperationalTaskEvidence[]; referencedIds: string[]; onLoadSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot> }) {
const ordered = useMemo(() => {
const referenced = new Set(referencedIds);
return [...evidence].sort((left, right) => Number(referenced.has(right.evidence_id)) - Number(referenced.has(left.evidence_id)));
}, [evidence, referencedIds]);
return (
<section aria-label="关键证据" className="surface-reading rounded-xl border border-slate-200/70 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={13} />关键证据</h3><span className="text-[11px] text-slate-500">{ordered.length} 条</span></div>
{ordered.length > 0 ? <div className="mt-2 grid gap-1.5">{ordered.map((item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadSnapshot} />)}</div> : <p className="mt-2 text-xs leading-5 text-slate-500">尚无可核验的支撑证据。</p>}
</section>
);
}
function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvidence; onLoadSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot> }) {
const [snapshot, setSnapshot] = useState<OperationalTaskDataSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true); setError(null);
try { setSnapshot(await onLoadSnapshot(item.evidence_id)); }
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); }
finally { setLoading(false); }
};
return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-slate-200/70 px-2.5">
<div className="border-t border-slate-200/70 py-2">
<dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl>
{item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
{snapshot ? <SnapshotSummary snapshot={snapshot} /> : null}
</div>
</Disclosure>;
}
function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) {
const rows = snapshotRows(snapshot.data).slice(0, 12);
return <section aria-label="数据快照" className="mt-2 rounded-lg bg-white px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-amber-700">数据已按有界查询范围截断。</p> : null}</section>;
}
function snapshotRows(value: unknown, prefix = ""): [string, string][] {
if (Array.isArray(value)) return value.slice(0, 6).flatMap((item, index) => snapshotRows(item, `${prefix}${index + 1}`));
if (value && typeof value === "object") return Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => snapshotRows(item, prefix ? `${prefix}.${key}` : key));
return [[prefix || "值", value === null || value === undefined ? "空" : String(value)]];
}
function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
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>;
}
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" | "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" | "save" | "confirm") => {
setSubmitting(kind); setError(null);
try {
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()) {
setError("请填写退回理由后再拒绝动作。");
return;
}
await onDecision(action.action_id, action.version, kind === "approve" ? "approved" : "rejected", kind === "reject" ? rejectComment.trim() : undefined);
}
} catch (nextError) {
const message = nextError instanceof Error ? nextError.message : "人工动作提交失败。";
setError(message);
setStale(/version|版本|409|conflict/i.test(message));
} finally { setSubmitting(null); }
};
return (
<article className="surface-reading rounded-xl border border-amber-200/80 p-3" data-action-id={action.action_id}>
<div className="flex items-start gap-2.5">
<StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon>
<div className="min-w-0 flex-1">
<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>
<ul className="mt-1 grid gap-1">
{action.evidence_refs.map((evidenceId) => {
const evidence = task.evidence.find((item) => item.evidence_id === evidenceId);
return <li key={evidenceId}>{evidence ? `${evidence.summary} · ${evidence.source_label}` : `证据 ${evidenceId}`}</li>;
})}
</ul>
</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}
{stale ? <Button variant="outline" size="sm" className="mt-2" onClick={() => { setStale(false); onRefresh(); }}><RefreshCw size={14} />刷新后重新确认</Button> : null}
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
<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>{!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"].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>
</article>
);
}
function ActionReceipt({ receipt }: { receipt: NonNullable<OperationalTaskAction["receipt"]> }) {
return <section aria-label="执行回执" className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
<p className="text-xs font-semibold text-emerald-800">执行回执</p>
<dl className="mt-1 grid gap-1 text-[11px] leading-5">
<EvidenceMeta label="回执编号" value={receipt.receipt_id} mono />
<EvidenceMeta label="外部状态" value={externalStatusLabel(receipt.external_status)} />
<EvidenceMeta label="提交时间" value={formatDateTime(receipt.submitted_at)} />
{receipt.completed_at ? <EvidenceMeta label="完成时间" value={formatDateTime(receipt.completed_at)} /> : null}
<EvidenceMeta label="说明" value={receipt.detail} />
</dl>
</section>;
}
function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> }) {
const rows = Object.entries(payload).flatMap(([key, value]) => {
if (key !== "parameters" && value && typeof value === "object" && !Array.isArray(value)) {
return Object.entries(value as Record<string, unknown>).map(([childKey, childValue]) => ({ key: `${key}.${childKey}`, value: childValue }));
}
return [{ key, value }];
});
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-slate-200/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-slate-500">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
}
function payloadLabel(key: string) {
const labels: Record<string, string> = {
title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级",
scheduled_at: "预约时间", location: "位置", assignee: "处理班组", command_type: "命令类型",
"target.kind": "对象类型", "target.id": "目标对象", parameters: "执行参数", impact_summary: "影响说明",
preconditions: "执行条件", expires_at: "失效时间"
};
return labels[key] ?? key;
}
function payloadValue(key: string, value: unknown) {
if (value === null || value === undefined || value === "") return "未指定";
if (key === "command_type") return knownLabel(value, { set_valve: "调节阀门" });
if (key === "target.kind") return knownLabel(value, { valve: "阀门", pump: "泵站", pipe: "管段" });
if (key === "work_order_type") return knownLabel(value, { general: "一般工单", water_quality_complaint: "水质投诉", burst_report: "爆管上报" });
if (key === "priority") return knownLabel(value, { normal: "普通", urgent: "紧急", high: "高", low: "低" });
if (key === "scheduled_at" || key === "expires_at") return typeof value === "string" ? formatDateTime(value) : String(value);
if (Array.isArray(value)) return value.map(String).join("、") || "无";
if (key === "parameters" && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).map(([parameter, item]) =>
parameter === "opening" ? `开度 ${String(item)}%` : `${parameter}=${String(item)}`
).join(";");
}
if (typeof value === "object") return Object.entries(value as Record<string, unknown>).map(([itemKey, item]) => `${itemKey}=${String(item)}`).join(";");
return String(value);
}
function knownLabel(value: unknown, labels: Record<string, string>) {
const raw = String(value);
return labels[raw] ?? raw;
}
function externalStatusLabel(value: string) {
return ({ completed: "已完成", submitted: "已提交", accepted: "已接收", failed: "失败", pending: "处理中" } as Record<string, string>)[value] ?? value;
}
function reachableFrom(start: string, outgoing: Map<string, ExecutionEdge[]>) {
const seen = new Set<string>();
const stack = [start];
while (stack.length > 0) { const current = stack.pop()!; if (seen.has(current)) continue; seen.add(current); for (const edge of outgoing.get(current) ?? []) stack.push(edge.to); }
return seen;
}
function topologicalNodes(plan: ExecutionPlanView) {
const remaining = new Map(plan.nodes.map((node) => [node.id, node]));
const result: ExecutionNode[] = [];
while (remaining.size > 0) {
const ready = plan.nodes.filter((node) => remaining.has(node.id) && plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
if (ready.length === 0) return result;
ready.forEach((node) => { result.push(node); remaining.delete(node.id); });
}
return result;
}
function executionNodePresentation(node: ExecutionNode) {
const tone: StatusTone = node.status === "completed" ? "success" : node.status === "running" ? "info" : node.status === "failed" ? "danger" : node.status === "waiting" ? "warning" : "neutral";
const Icon = node.status === "completed" ? CheckCircle2 : node.status === "running" ? Loader2 : node.status === "waiting" || node.status === "failed" ? AlertTriangle : Clock3;
const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "不适用", cancelled: "已取消" } as const;
return { tone, Icon, label: labels[node.status] };
}
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]; }
function executionTone(status: OperationalTaskAction["execution_status"]): StatusTone { return status === "succeeded" ? "success" : status === "failed" || status === "unsupported" ? "danger" : status === "submitted" || status === "unknown" ? "warning" : "neutral"; }