Files
next-tjwater-frontend/src/features/workbench/components/agent-execution-plan-panel.tsx
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

685 lines
51 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 { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
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,
GitFork,
Loader2,
MessageSquare,
RefreshCw,
Route,
ShieldCheck,
UserCheck
} from "lucide-react";
import type { AgentExecutionPlan } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
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";
import type { TaskExecutionHistoryTarget } 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;
evidence_refs?: string[];
action_refs?: string[];
};
type ExecutionFlowContext = Pick<OperationalTask, "evidence" | "access_requests" | "actions">;
type ExecutionEdge = {
id?: string | null;
from: string;
to: string;
label?: string | null;
condition?: string | null;
};
export type ExecutionPlanView = {
nodes: ExecutionNode[];
edges: ExecutionEdge[];
};
type AgentExecutionPlanPanelProps = {
onLocate?: (targets: import("../api/investigation-report").InvestigationMapTarget[]) => void;
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | 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;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
};
export function AgentExecutionPlanPanel({
onLocate,
task,
onContinue,
onViewExecutionHistory,
onDecision,
onExecute,
onDraftUpdate,
onConfirmDispatch,
onAccessDecision,
onReconcile,
onLoadEvidenceSnapshot,
onRefresh,
onLoadReports,
onAnswerQuestion,
onReportViewed,
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
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;
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 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
? "任务已持久化,等待 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">
<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">
<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" ? 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">
{planSummary}
</p>
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">
{taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)}
</p>
</div>
{task.task_type === "external_work_order" && externalEvidence.length > 0 ? (
<ExternalProgress evidence={externalEvidence} />
) : plan ? (
<>
<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>
)}
</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}
<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>
);
}
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} context={context} onLoadEvidenceSnapshot={onLoadEvidenceSnapshot} />
: <ExactDependencyList plan={plan} nodeActions={nodeActions} />
}</div>;
}
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 visible = new Set<string>();
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);
}
return { activeEdges, visible };
}
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>; visible: Set<string>;
context?: ExecutionFlowContext; onLoadEvidenceSnapshot?: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
}) {
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;
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]} 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>{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">{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} 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} 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>;
}
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-[var(--ink-muted)]">历史流程按原始依赖展示;未标注路线或并行类型。</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-[var(--ink-muted)]">前置:{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, 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 ? <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>
{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>
);
}
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-[var(--surface-divider)]" 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-[var(--surface-divider)]/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-[var(--ink-muted)]">{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-[var(--surface-divider)]/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-[var(--ink-muted)]">{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-[var(--ink-muted)]">尚无可核验的支撑证据。</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 wrap-anywhere text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
<div className="border-t border-[var(--surface-divider)]/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 min-w-0 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="wrap-anywhere 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-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</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 min-w-0 grid-cols-[48px_minmax(0,1fr)] gap-2"><dt className="min-w-0 wrap-anywhere font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("min-w-0 wrap-anywhere 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-[var(--status-warning-border)]/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-[var(--ink-muted)]">关联步骤 {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"><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-[var(--ink-muted)]">等待具有该数据源读取权限的项目成员审核。</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 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") => {
setSubmitting(kind); setError(null);
try {
if (kind === "save") {
if (!onDraftUpdate) throw new Error("工单草稿编辑不可用");
await onDraftUpdate(action.action_id, action.version, workOrderDraft(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-[var(--status-warning-border)]/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 ? "命令下发确认" : 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>
{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 })} />
<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(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">
<p className="font-semibold text-[var(--action-blue-hover)]">确认依据</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} 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>
{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-[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(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>
</div>
</article>
);
}
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)} />
<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-[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: "优先级",
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) { return formatDisplayDateTime(value, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); }
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"; }