630 lines
42 KiB
TypeScript
630 lines
42 KiB
TypeScript
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"; }
|