feat(workbench): redesign investigation reports and diagrams
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { InvestigationReportView } from "./investigation-report-view";
|
||||
import { InvestigationTaskReport } from "./investigation-task-report";
|
||||
import type { InvestigationDiscussion } from "../api/investigation-report";
|
||||
import type { OperationalTaskReportPage } from "../api/operational-task-client";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileText,
|
||||
GitFork,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -38,8 +41,12 @@ type ExecutionNode = {
|
||||
selected_edge_id?: string | null;
|
||||
gate_ref?: string | null;
|
||||
revisits_node_id?: string | null;
|
||||
evidence_refs?: string[];
|
||||
action_refs?: string[];
|
||||
};
|
||||
|
||||
type ExecutionFlowContext = Pick<OperationalTask, "evidence" | "access_requests" | "actions">;
|
||||
|
||||
type ExecutionEdge = {
|
||||
id?: string | null;
|
||||
from: string;
|
||||
@@ -55,7 +62,7 @@ export type ExecutionPlanView = {
|
||||
|
||||
type AgentExecutionPlanPanelProps = {
|
||||
task: OperationalTask;
|
||||
onContinue: () => void;
|
||||
onContinue: (discussion?: InvestigationDiscussion) => 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>;
|
||||
@@ -64,6 +71,9 @@ type AgentExecutionPlanPanelProps = {
|
||||
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onRefresh: () => void;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
};
|
||||
|
||||
export function AgentExecutionPlanPanel({
|
||||
@@ -76,8 +86,17 @@ export function AgentExecutionPlanPanel({
|
||||
onAccessDecision,
|
||||
onReconcile,
|
||||
onLoadEvidenceSnapshot,
|
||||
onRefresh
|
||||
onRefresh,
|
||||
onLoadReports,
|
||||
onReportViewed,
|
||||
focusActionId
|
||||
}: AgentExecutionPlanPanelProps) {
|
||||
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
|
||||
onContinue={onContinue} onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId}
|
||||
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
|
||||
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
|
||||
renderEvidence={(item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} />}
|
||||
/>;
|
||||
const plan = task.latest_plan?.plan;
|
||||
const report = task.latest_report?.report;
|
||||
const planGeneration = task.latest_plan?.execution_generation;
|
||||
@@ -92,9 +111,19 @@ export function AgentExecutionPlanPanel({
|
||||
: 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 visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
|
||||
? activePlanGraph(plan).visible : null;
|
||||
const visiblePlanNodes = plan && visiblePlanNodeIds
|
||||
? plan.nodes.filter((node) => visiblePlanNodeIds.has(node.id))
|
||||
: plan?.nodes ?? [];
|
||||
const completed = visiblePlanNodes.filter((node) => node.status === "completed").length;
|
||||
const active = visiblePlanNodes.filter((node) => node.status === "running").length;
|
||||
const waiting = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").length;
|
||||
const selectedRoutes = visiblePlanNodes.filter((node) => node.kind === "decision" && node.selected_edge_id).map((node) => {
|
||||
const edge = plan?.edges.find((item) => item.id === node.selected_edge_id && item.from === node.id);
|
||||
return edge ? `${node.title}:${edge.condition}` : null;
|
||||
}).filter((item): item is string => Boolean(item));
|
||||
const waitingNodes = visiblePlanNodes.filter((node) => node.status === "waiting" || node.owner === "human" && node.status === "pending").map((node) => node.title);
|
||||
const externalEvidence = task.evidence.filter((item) => item.kind === "external_feedback");
|
||||
const taskActive = task.status === "queued" || task.status === "running";
|
||||
const planSummary = !plan
|
||||
@@ -123,6 +152,7 @@ export function AgentExecutionPlanPanel({
|
||||
|
||||
return (
|
||||
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
|
||||
<InvestigationReportView task={task} reportLabel={reportLabel} />
|
||||
<section className="surface-reading overflow-hidden rounded-xl border border-[var(--surface-divider)]/70">
|
||||
<div className="space-y-2 border-b border-[var(--surface-divider)]/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -130,8 +160,8 @@ export function AgentExecutionPlanPanel({
|
||||
<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 tone={task.status === "failed" ? "danger" : task.status === "waiting_human" ? "warning" : task.status === "completed" ? report?.outcome === "insufficient_data" && reportState === "current" ? "warning" : "success" : "info"}>
|
||||
{task.lifecycle_status === "completed" && reportState === "current" && report?.outcome === "insufficient_data" ? "已结束 · 证据不足" : taskStatusLabel(task.status)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-600" role="status">
|
||||
@@ -144,7 +174,15 @@ export function AgentExecutionPlanPanel({
|
||||
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
|
||||
<ExternalProgress evidence={externalEvidence} />
|
||||
) : plan ? (
|
||||
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
|
||||
<>
|
||||
<div className="border-b border-[var(--surface-divider)]/70 px-3 py-2.5 text-xs leading-5 text-slate-700">
|
||||
<p className="font-semibold text-slate-900">{plan.title} <span className="font-normal text-[var(--ink-muted)]">· 第 {task.latest_plan?.revision} 版</span></p>
|
||||
<p className="mt-0.5 text-slate-600">规划依据:{plan.reason}</p>
|
||||
{plan.schema === "execution-plan@2" ? <p className="mt-0.5 text-slate-600">{selectedRoutes.length ? `实际路线:${selectedRoutes.join(";")}` : plan.nodes.some((node) => node.kind === "decision") ? "实际路线:待判断" : "实际路线:按计划执行"}</p> : null}
|
||||
{waitingNodes.length > 0 ? <p className="mt-0.5 text-[var(--status-warning-foreground)]">等待:{waitingNodes.join("、")}</p> : null}
|
||||
</div>
|
||||
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
</>
|
||||
) : (
|
||||
<p className="p-3 text-xs leading-5 text-[var(--ink-muted)]">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
|
||||
)}
|
||||
@@ -156,38 +194,6 @@ export function AgentExecutionPlanPanel({
|
||||
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 ? (
|
||||
@@ -209,7 +215,7 @@ export function AgentExecutionPlanPanel({
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onContinue}>
|
||||
<Button variant="outline" size="sm" onClick={() => onContinue()}>
|
||||
<MessageSquare size={14} aria-hidden="true" />Agent 协作
|
||||
</Button>
|
||||
</div>
|
||||
@@ -217,60 +223,58 @@ export function AgentExecutionPlanPanel({
|
||||
);
|
||||
}
|
||||
|
||||
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> }) {
|
||||
export function ExecutionPlanSteps({ plan, nodeActions = {}, context, onLoadEvidenceSnapshot }: {
|
||||
plan: ExecutionPlanView | AgentExecutionPlan | OperationalTaskPlan;
|
||||
nodeActions?: Record<string, ReactNode>;
|
||||
context?: ExecutionFlowContext;
|
||||
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
}) {
|
||||
return <div aria-label="调查执行步骤" className="px-3 py-3">{
|
||||
plan.nodes.some((node) => node.kind)
|
||||
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} />
|
||||
? <SemanticExecutionFlow plan={plan} nodeActions={nodeActions} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
|
||||
}</div>;
|
||||
}
|
||||
|
||||
function SemanticExecutionFlow({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
|
||||
type SemanticFlowProps = {
|
||||
plan: ExecutionPlanView;
|
||||
nodeActions: Record<string, ReactNode>;
|
||||
context?: ExecutionFlowContext;
|
||||
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
};
|
||||
|
||||
function SemanticExecutionFlow({ plan, nodeActions, context, onLoadEvidenceSnapshot }: SemanticFlowProps) {
|
||||
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
|
||||
const { activeEdges, visible } = activePlanGraph(plan);
|
||||
const roots = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id));
|
||||
return <div className="space-y-2">
|
||||
<div className="space-y-2 border-l-2 border-[var(--surface-divider)] 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()} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function activePlanGraph(plan: ExecutionPlanView) {
|
||||
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);
|
||||
const queue = plan.nodes.filter((node) => !plan.edges.some((edge) => edge.to === node.id)).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>;
|
||||
return { activeEdges, visible };
|
||||
}
|
||||
|
||||
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen }: {
|
||||
function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions, seen, visible, context, onLoadEvidenceSnapshot }: {
|
||||
nodeId: string; stopId: string | null; plan: ExecutionPlanView; activeEdges: ExecutionEdge[];
|
||||
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>;
|
||||
nodeById: Map<string, ExecutionNode>; nodeActions: Record<string, ReactNode>; seen: Set<string>; visible: Set<string>;
|
||||
context?: ExecutionFlowContext; onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
}) {
|
||||
if (nodeId === stopId || seen.has(nodeId)) return null;
|
||||
const node = nodeById.get(nodeId);
|
||||
@@ -280,20 +284,36 @@ function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions
|
||||
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;
|
||||
const selectedRoute = node.kind === "decision" ? allRoutes.find((edge) => edge.id === node.selected_edge_id) : null;
|
||||
const unchosenRoutes = node.kind === "decision" ? allRoutes.filter((edge) => edge.id !== node.selected_edge_id) : [];
|
||||
const claimed = new Set<string>();
|
||||
const alternativeGroups = unchosenRoutes.map((edge) => {
|
||||
const reachable = reachableFrom(edge.to, new Map(plan.nodes.map((item) => [item.id, plan.edges.filter((route) => route.from === item.id)])));
|
||||
const nodes = topologicalNodes(plan).filter((item) => reachable.has(item.id) && !visible.has(item.id) && !claimed.has(item.id));
|
||||
nodes.forEach((item) => claimed.add(item.id));
|
||||
return { edge, nodes };
|
||||
});
|
||||
const predecessors = node.kind === "parallel_join" ? plan.edges.filter((edge) => edge.to === node.id).map((edge) => nodeById.get(edge.from)).filter((item): item is ExecutionNode => Boolean(item)) : [];
|
||||
return <div className="space-y-2">
|
||||
<ExecutionNodeCard node={node} action={nodeActions[node.id]} />
|
||||
<ExecutionNodeCard node={node} action={nodeActions[node.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} />
|
||||
{node.kind === "decision" ? <div className="rounded-lg bg-[var(--action-soft)]/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-[var(--ink-muted)]">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
|
||||
<span className="font-semibold">路线选择:</span>{selectedRoute?.condition ?? selectedRoute?.label ?? "等待判断"}
|
||||
{alternativeGroups.length > 0 ? <Disclosure label={`${selectedRoute ? "其他规划路线" : "待选路线"} ${alternativeGroups.length} 项`} className="mt-1 border-t border-[var(--surface-border)]">
|
||||
<div className="grid gap-2 border-t border-[var(--surface-border)] py-2">{alternativeGroups.map(({ edge, nodes }) => <section key={edge.id ?? edge.to} className="rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2" aria-label={`规划路线 ${edge.condition ?? edge.label ?? edge.to}`}>
|
||||
<p className="font-semibold text-slate-700">{edge.condition ?? edge.label ?? "未命名路线"} <span className="font-normal text-[var(--ink-muted)]">· {selectedRoute ? "未选择" : "待判断"}</span></p>
|
||||
{nodes.length > 0 ? <ol className="mt-1.5 grid gap-1.5">{nodes.map((item) => <li key={item.id}><ExecutionNodeCard node={item} action={nodeActions[item.id]} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} nodeById={nodeById} compact /></li>)}</ol> : <p className="mt-1 text-[var(--ink-muted)]">此路线在后续节点汇合。</p>}
|
||||
</section>)}</div>
|
||||
</Disclosure> : null}
|
||||
</div> : null}
|
||||
{predecessors.length > 0 ? <p className="rounded-lg bg-[var(--surface-reading)] px-3 py-2 text-[11px] leading-5 text-slate-600">汇合前置:{predecessors.map((item) => `${item.title}(${executionNodePresentation(item).label})`).join("、")}</p> : null}
|
||||
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-[var(--surface-border)] bg-[var(--action-soft)]/40 p-2">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-[var(--action-blue-pressed)]"><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-[var(--surface-divider)]/70 bg-[var(--surface-reading)] p-2">
|
||||
<div className="grid gap-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 border-l-2 border-[var(--surface-border)] pl-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} />
|
||||
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
|
||||
</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}
|
||||
</section> : outgoing[0] ? <SemanticPath nodeId={outgoing[0].to} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
|
||||
{join ? <SemanticPath nodeId={join.id} stopId={stopId} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} visible={visible} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} /> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -315,19 +335,36 @@ function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; n
|
||||
})}</ol></div>;
|
||||
}
|
||||
|
||||
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
|
||||
function ExecutionNodeCard({ node, action, compact = false, context, onLoadEvidenceSnapshot, nodeById }: {
|
||||
node: ExecutionNode; action?: ReactNode; compact?: boolean; context?: ExecutionFlowContext;
|
||||
onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
nodeById?: Map<string, ExecutionNode>;
|
||||
}) {
|
||||
const { tone, Icon, label } = executionNodePresentation(node);
|
||||
const evidence = node.evidence_refs?.map((id) => context?.evidence.find((item) => item.evidence_id === id) ?? id) ?? [];
|
||||
const gate = node.gate_ref && context ? context.access_requests.find((item) => item.access_request_id === node.gate_ref) ?? context.actions.find((item) => item.action_id === node.gate_ref) : null;
|
||||
const relatedActions = node.action_refs?.map((id) => context?.actions.find((item) => item.action_id === id) ?? id) ?? [];
|
||||
const hasDetails = Boolean(node.kind && (evidence.length || node.gate_ref || node.revisits_node_id || relatedActions.length));
|
||||
return (
|
||||
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/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-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
|
||||
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "审核关口" }[node.kind]}</p> : null}
|
||||
{node.kind ? <p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">负责:{node.owner === "human" ? "人工" : "Agent"}</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}
|
||||
{hasDetails ? <Disclosure label={`查看关联详情${evidence.length ? ` · ${evidence.length} 条证据` : ""}`} className="mt-1 border-t border-[var(--surface-divider)]/70">
|
||||
<div className="grid gap-2 border-t border-[var(--surface-divider)]/70 py-2 text-[11px] leading-5 text-slate-700">
|
||||
{node.revisits_node_id ? <p>再次核验:{nodeById?.get(node.revisits_node_id)?.title ?? `节点 ${node.revisits_node_id}`}</p> : null}
|
||||
{evidence.length > 0 ? <div><p className="font-semibold">节点依据</p><div className="mt-1 grid gap-1">{evidence.map((item) => typeof item === "string" ? <p key={item} className="text-[var(--status-warning-foreground)]">证据 {item} 不可用</p> : onLoadEvidenceSnapshot ? <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadEvidenceSnapshot} /> : <p key={item.evidence_id}>{item.summary} · {item.source_label}</p>)}</div></div> : null}
|
||||
{node.gate_ref ? <p>审核关联:{gate ? "scopes" in gate ? `数据访问${gate.status === "approved" ? "已批准" : gate.status === "rejected" ? "已拒绝" : "待审核"},${gate.reason}` : `动作${approvalLabel(gate.approval_status)},${gate.reason}` : "关联记录不可用"}</p> : null}
|
||||
{relatedActions.length > 0 ? <div><p className="font-semibold">关联动作</p>{relatedActions.map((item) => <p key={typeof item === "string" ? item : item.action_id}>{typeof item === "string" ? `动作 ${item} 不可用` : `${item.reason} · ${approvalLabel(item.approval_status)} · ${executionLabel(item.execution_status)}`}</p>)}</div> : null}
|
||||
</div>
|
||||
</Disclosure> : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -448,8 +485,10 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
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 linkedAppointment = !command && task.task_type === "event_investigation";
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(() => workOrderDraft(action.payload));
|
||||
const displayedPayload = command ? action.payload : workOrderDraft(action.payload);
|
||||
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") => {
|
||||
@@ -457,7 +496,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
try {
|
||||
if (kind === "save") {
|
||||
if (!onDraftUpdate) throw new Error("工单草稿编辑不可用");
|
||||
await onDraftUpdate(action.action_id, action.version, draft);
|
||||
await onDraftUpdate(action.action_id, action.version, workOrderDraft(draft));
|
||||
setSavedVersion(action.version);
|
||||
setEditing(false);
|
||||
} else if (kind === "confirm") {
|
||||
@@ -483,17 +522,19 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : linkedAppointment ? "关联预约工单" : "预约工单创建确认"}</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-[var(--ink-muted)]">原因:</span>{action.reason}</p>
|
||||
<ActionPayloadSummary payload={action.payload} />
|
||||
{linkedAppointment ? <p className="mt-2 rounded-lg bg-sky-50/80 px-2.5 py-2 text-xs leading-5 text-slate-700">调查任务只保留创建关联。创建后请到“预约任务”查看派发进度、现场反馈和复令。</p> : null}
|
||||
<ActionPayloadSummary payload={displayedPayload} />
|
||||
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] 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="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] 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="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] 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>
|
||||
<label className="grid gap-1 text-[11px] font-semibold text-[var(--ink-muted)]">工单归属<span className="form-field flex min-h-10 items-center rounded-lg border border-[var(--surface-divider)] bg-slate-50 px-2 text-xs font-normal text-slate-700">预约任务</span></label>
|
||||
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] 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>
|
||||
<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 className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(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-[var(--action-soft)]/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
|
||||
@@ -506,7 +547,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
|
||||
{action.receipt ? <ActionReceipt receipt={action.receipt} title={linkedAppointment ? "预约工单创建回执" : undefined} /> : null}
|
||||
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] 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-[var(--surface-divider)]/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-[var(--surface-divider)]/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-[var(--ink-muted)]">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
|
||||
@@ -515,8 +556,8 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] 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 === "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(workOrderDraft(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>
|
||||
@@ -525,9 +566,9 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
function ActionReceipt({ receipt, title = "执行回执" }: { receipt: NonNullable<OperationalTaskAction["receipt"]>; title?: string }) {
|
||||
return <section aria-label={title} className="mt-2 rounded-lg bg-emerald-50/70 px-2.5 py-2">
|
||||
<p className="text-xs font-semibold text-emerald-800">{title}</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)} />
|
||||
@@ -548,6 +589,10 @@ function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> })
|
||||
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-[var(--surface-divider)]/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-[var(--ink-muted)]">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
|
||||
}
|
||||
|
||||
function workOrderDraft(payload: Record<string, unknown>) {
|
||||
return { ...payload, work_order_type: "general" };
|
||||
}
|
||||
|
||||
function payloadLabel(key: string) {
|
||||
const labels: Record<string, string> = {
|
||||
title: "工单标题", description: "工作内容", work_order_type: "工单类型", priority: "优先级",
|
||||
@@ -562,7 +607,7 @@ 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 === "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("、") || "无";
|
||||
@@ -605,7 +650,7 @@ function topologicalNodes(plan: ExecutionPlanView) {
|
||||
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;
|
||||
const labels = { pending: "待执行", running: "执行中", waiting: "待人工/资料", completed: "已完成", failed: "失败", skipped: "未进入", cancelled: "已取消" } as const;
|
||||
return { tone, Icon, label: labels[node.status] };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user