feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
@@ -0,0 +1,283 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { type AgentExecutionPlan } from "@/features/agent";
import type { OperationalTask } from "../api/operational-task-client";
import { AgentExecutionPlanPanel, ExecutionPlanSteps } from "./agent-execution-plan-panel";
describe("execution step cards", () => {
it("shows parallel cards, exact join dependencies and escaped model content", () => {
const plan: AgentExecutionPlan = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1,
updated_at: "2026-09-23T01:00:00.000Z", title: "调查", reason: "核验",
nodes: ["接入", "上游", "邻区", "汇合"].map((title, index) => ({
id: String(index), title, status: index === 3 ? "waiting" : "completed",
owner: index === 3 ? "human" : "agent", detail: '<script>alert("test")</script>', evidence: []
})),
edges: [{ from: "0", to: "1", label: "" }, { from: "0", to: "2", label: "" }, { from: "1", to: "3", label: "上游正常" }, { from: "2", to: "3", label: "投诉集中" }]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByText("并行处理")).toBeInTheDocument();
expect(screen.getByLabelText("并行分枝 2 项")).toHaveAttribute("data-execution-layout", "branch");
expect(screen.getByText("2 条独立分枝")).toBeInTheDocument();
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(4);
expect(container.querySelector("script")).toBeNull();
expect(screen.queryByText(/前置:/)).not.toBeInTheDocument();
expect(screen.getByText("分枝 1")).toBeInTheDocument();
expect(screen.getByText("分枝 2")).toBeInTheDocument();
expect(screen.queryByText("每条分枝按自身依赖推进,不要求同层节点同时完成。")).not.toBeInTheDocument();
expect(screen.getByText("待人工/资料")).toBeInTheDocument();
});
it("renders a four-way fork and a later two-way fork without inventing cross-branch edges", () => {
const node = (id: string, title: string, status: AgentExecutionPlan["nodes"][number]["status"] = "completed") => ({
id, title, status, owner: "agent" as const, detail: `${title}详情`, evidence: []
});
const plan: AgentExecutionPlan = {
schema_version: "execution-plan@1", session_id: "s-nested", revision: 1,
updated_at: "2026-09-23T01:00:00.000Z", title: "多轮调查", reason: "按真实依赖推进",
nodes: [
node("start", "接收事件"),
node("a1", "上游核查"), node("a2", "上游复核"),
node("b1", "邻区投诉"),
node("c1", "压力比对"), node("c2", "水力校核"), node("c3", "阀门排查"),
node("d1", "运维记录"),
node("join1", "阶段判断"),
node("e1", "二次供水核查", "running"), node("e2", "水箱记录", "waiting"),
node("f1", "现场采样", "waiting"),
node("join2", "形成结论", "pending")
],
edges: [
{ from: "start", to: "a1", label: "" }, { from: "a1", to: "a2", label: "" }, { from: "a2", to: "join1", label: "" },
{ from: "start", to: "b1", label: "" }, { from: "b1", to: "join1", label: "" },
{ from: "start", to: "c1", label: "" }, { from: "c1", to: "c2", label: "" }, { from: "c2", to: "c3", label: "" }, { from: "c3", to: "join1", label: "" },
{ from: "start", to: "d1", label: "" }, { from: "d1", to: "join1", label: "" },
{ from: "join1", to: "e1", label: "" }, { from: "e1", to: "e2", label: "" }, { from: "e2", to: "join2", label: "" },
{ from: "join1", to: "f1", label: "" }, { from: "f1", to: "join2", label: "" }
]
};
const { container } = render(<ExecutionPlanSteps plan={plan} />);
expect(screen.getByLabelText("并行分枝 4 项")).toBeVisible();
expect(screen.getByLabelText("并行分枝 2 项")).toBeVisible();
expect(screen.getAllByText("分枝汇合")).toHaveLength(2);
expect(container.querySelectorAll("[data-execution-node]")).toHaveLength(plan.nodes.length);
expect(screen.queryByText(/前置工作与条件|前置:/)).not.toBeInTheDocument();
});
});
const taskBase = {
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation",
workflow: "water_quality_complaint",
lifecycle_status: "waiting_human",
active_run: null,
status: "waiting_human",
priority: "normal",
revision: 2,
execution_generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
title: "水质投诉调查",
summary: "居民反映异味",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "water_quality_complaint" },
trigger: { kind: "manual", occurred_at: "2026-09-23T01:00:00Z", detail: "演示小区" },
scheduled_for: null,
agent_session_id: "execution-session",
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: 1,
created_at: "2026-09-23T01:00:00Z",
updated_at: "2026-09-23T01:10:00Z",
latest_plan: null,
latest_report: {
revision: 1, execution_generation: 1, source_session_id: "execution-session", published_at: "2026-09-23T01:09:00Z", published_by: "agent",
report: { title: "调查报告", current_conclusion: "上游指标正常,待核查二次供水。", conclusion_boundary: "等待现场采样。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] }
},
evidence: [{ evidence_id: "e-1", kind: "system_data", summary: "上游余氯处于正常范围", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [{ action_id: "44444444-4444-4444-8444-444444444444", task_id: "22222222-2222-4222-8222-222222222222", version: 3, kind: "create_work_order", node_id: null, reason: "需要现场采样", payload: { title: "现场采样", location: "演示小区", scheduled_at: "2026-09-23T02:00:00Z" }, evidence_refs: ["e-1"], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T01:09:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }],
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false },
work_order_snapshot: null
} as OperationalTask;
function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<typeof AgentExecutionPlanPanel>> = {}) {
const props = {
task,
onContinue: vi.fn(),
onDecision: vi.fn(async () => undefined),
onExecute: vi.fn(async () => undefined),
onReconcile: vi.fn(async () => undefined),
onRefresh: vi.fn(),
onLoadEvidenceSnapshot: vi.fn(async () => { throw new Error("no snapshot"); }),
...overrides
};
render(<AgentExecutionPlanPanel {...props} />);
return props;
}
describe("operational task approval and evidence", () => {
it("distinguishes the current report from a previous execution without exposing generation numbers", () => {
taskPanel(taskBase);
expect(screen.getByText("当前结论", { exact: true })).toBeVisible();
const previousReport = {
...taskBase,
status: "running" as const,
execution_generation: 2,
latest_report: { ...taskBase.latest_report!, execution_generation: 1 }
};
const { rerender } = render(<AgentExecutionPlanPanel
task={previousReport}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,本轮分析中", { exact: true })).toBeVisible();
expect(screen.queryByText(/第\s*[12]\s*代|generation/i)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "failed" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务失败", { exact: true })).toBeVisible();
expect(screen.queryByText("上一轮报告,本轮分析中", { exact: true })).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, status: "cancelled" }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("上一轮报告,当前任务已取消", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...previousReport, latest_report: { ...previousReport.latest_report!, execution_generation: null } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={vi.fn()}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByText("历史报告", { exact: true })).toBeVisible();
});
it("labels current, previous and legacy execution plans without presenting old progress as current", () => {
const latestPlan: NonNullable<OperationalTask["latest_plan"]> = {
revision: 1,
execution_generation: 1,
source_session_id: "execution-session",
published_at: "2026-09-23T01:05:00Z",
published_by: "agent",
plan: {
schema: "execution-plan@1",
title: "调查流程",
reason: "核对真实状态",
nodes: [{
id: "investigate",
title: "核查事件",
owner: "agent",
status: "completed",
detail: "上一轮已完成。",
evidence_refs: [],
action_refs: []
}],
edges: []
}
};
const currentPlanTask = { ...taskBase, latest_plan: latestPlan };
const props = {
onContinue: vi.fn(), onDecision: vi.fn(), onExecute: vi.fn(), onReconcile: vi.fn(),
onRefresh: vi.fn(), onLoadEvidenceSnapshot: vi.fn()
};
const { rerender } = render(<AgentExecutionPlanPanel task={currentPlanTask} {...props} />);
expect(screen.getByText("已完成 1 项 · 执行中 0 项 · 待人工或资料 0 项", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "running", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,本轮规划中", { exact: true })).toBeVisible();
expect(screen.queryByText(/已完成 1 项/)).not.toBeInTheDocument();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, status: "failed", execution_generation: 2 }}
{...props}
/>);
expect(screen.getByText("上一轮流程,当前任务失败", { exact: true })).toBeVisible();
rerender(<AgentExecutionPlanPanel
task={{ ...currentPlanTask, latest_plan: { ...latestPlan, execution_generation: null } }}
{...props}
/>);
expect(screen.getByText("历史流程", { exact: true })).toBeVisible();
expect(screen.queryByText(/generation|第\s*\d+\s*代/i)).not.toBeInTheDocument();
});
it("shows exact payload and evidence metadata to a viewer without action controls", () => {
taskPanel(taskBase);
expect(screen.getByText("工单标题")).toBeVisible();
expect(screen.getByText("位置")).toBeVisible();
expect(screen.getByText("预约时间")).toBeVisible();
expect(screen.getByText("现场采样")).toBeVisible();
expect(screen.getByText("演示小区")).toBeVisible();
expect(screen.getByText("确认依据")).toBeVisible();
expect(screen.getByText(/上游余氯处于正常范围 · SCADA 水质监测/)).toBeVisible();
expect(screen.getByText(/当前账号仅可查看/)).toBeVisible();
expect(screen.queryByText(/operational_action\.work_order\.approve/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "批准创建工单" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("上游余氯处于正常范围"));
expect(screen.getByText("SCADA 水质监测")).toBeVisible();
expect(screen.getByText("系统数据")).toBeVisible();
});
it("keeps pending state after a decision network error", async () => {
const onDecision = vi.fn(async () => { throw new Error("network unavailable"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
expect(await screen.findByRole("alert")).toHaveTextContent("当前状态未按成功处理");
expect(screen.getByText("待确认")).toBeVisible();
});
it("requires refresh and reconfirmation after a version conflict", async () => {
const onRefresh = vi.fn();
const onDecision = vi.fn(async () => { throw new Error("409 version conflict"); });
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_decide_work_orders: true } }, { onDecision, onRefresh });
fireEvent.click(screen.getByRole("button", { name: "批准创建工单" }));
const refresh = await screen.findByRole("button", { name: "刷新后重新确认" });
fireEvent.click(refresh);
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce());
});
it("allows unknown submissions to reconcile and shows a persisted receipt", async () => {
const onReconcile = vi.fn(async () => undefined);
const unknownAction = {
...taskBase.actions[0],
approval_status: "approved" as const,
execution_status: "unknown" as const,
execution_request_id: "55555555-5555-4555-8555-555555555555"
};
const receiptAction = {
...unknownAction,
execution_status: "succeeded" as const,
receipt: {
adapter: "peripheral",
receipt_id: "receipt-20260923-01",
submitted_at: "2026-09-23T01:10:00Z",
completed_at: "2026-09-23T01:11:00Z",
external_status: "completed",
detail: "外围工单创建完成"
}
};
const { rerender } = render(<AgentExecutionPlanPanel
task={{ ...taskBase, actions: [unknownAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
fireEvent.click(screen.getByRole("button", { name: "核对执行回执" }));
await waitFor(() => expect(onReconcile).toHaveBeenCalledWith(unknownAction.action_id, unknownAction.version));
rerender(<AgentExecutionPlanPanel
task={{ ...taskBase, actions: [receiptAction], permissions: { ...taskBase.permissions, can_execute_work_orders: true } }}
onContinue={vi.fn()} onDecision={vi.fn()} onExecute={vi.fn()} onReconcile={onReconcile}
onRefresh={vi.fn()} onLoadEvidenceSnapshot={vi.fn()}
/>);
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("receipt-20260923-01");
expect(screen.getByRole("region", { name: "执行回执" })).toHaveTextContent("外围工单创建完成");
});
});
@@ -0,0 +1,555 @@
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;
};
type ExecutionEdge = {
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>;
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
};
export function AgentExecutionPlanPanel({
task,
onContinue,
onDecision,
onExecute,
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} 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>
<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}
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"><ExecutionFlow plan={plan} nodeActions={nodeActions} depth={0} /></div>;
}
function ExecutionFlow({ plan, nodeActions, depth }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; depth: number }) {
const layout = createBranchLayout(plan);
if (!layout || depth > 8) return <ExactDependencyList plan={plan} nodeActions={nodeActions} />;
return (
<div className="grid gap-2">
<ExecutionChain nodes={layout.prefix} plan={plan} nodeActions={nodeActions} />
{layout.branches.length > 0 ? (
<section aria-label={`并行分枝 ${layout.branches.length} 项`} data-execution-layout="branch" className="min-w-0 py-1">
<div className="mb-2 flex items-center gap-2">
<StatusIcon tone="info" shape="circle" size="md"><GitFork size={14} /></StatusIcon>
<span className="text-xs font-semibold text-slate-800">并行处理</span>
<StatusBadge tone="info">{layout.branches.length} 条独立分枝</StatusBadge>
</div>
<div className="grid snap-x snap-mandatory auto-cols-[86%] grid-flow-col gap-2 overflow-x-auto overscroll-x-contain pb-1 sm:snap-none sm:grid-flow-row sm:grid-cols-2 sm:overflow-visible">
{layout.branches.map((branch, index) => {
const firstEdge = plan.edges.find((edge) => edge.to === branch[0]?.id);
return (
<section key={branch.map((node) => node.id).join("-")} className="surface-control min-w-0 snap-start rounded-xl border border-slate-200/70 p-2.5">
<p className="mb-2 text-[11px] font-semibold text-slate-500">分枝 {index + 1}{firstEdge?.condition ? ` · ${firstEdge.condition}` : ""}</p>
<ExecutionChain nodes={branch} plan={plan} nodeActions={nodeActions} compact />
</section>
);
})}
</div>
</section>
) : null}
{layout.suffix.length > 0 ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-[11px] text-slate-500"><span className="h-px flex-1 bg-slate-200" /><span>分枝汇合</span><span className="h-px flex-1 bg-slate-200" /></div>
<ExecutionFlow plan={subPlan(plan, layout.suffix.map((node) => node.id))} nodeActions={nodeActions} depth={depth + 1} />
</div>
) : null}
</div>
);
}
function ExecutionChain({ nodes, plan, nodeActions, compact = false }: { nodes: ExecutionNode[]; plan: ExecutionPlanView; nodeActions: Record<string, ReactNode>; compact?: boolean }) {
return <div className="grid gap-0">{nodes.map((node, index) => {
const edge = index > 0 ? plan.edges.find((item) => item.from === nodes[index - 1].id && item.to === node.id) : undefined;
return <div key={node.id}>{index > 0 ? <div className="flex min-h-6 items-center gap-2 px-3 text-[10px] text-slate-500"><span className="h-6 w-px bg-slate-300" />{edge?.condition ? <span>{edge.condition}</span> : null}</div> : null}<ExecutionNodeCard node={node} action={nodeActions[node.id]} compact={compact} /></div>;
})}</div>;
}
function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
return <ol aria-label="调查执行步骤" className="grid gap-2">{topologicalNodes(plan).map((node) => <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} /></li>)}</ol>;
}
function subPlan(plan: ExecutionPlanView, nodeIds: string[]): ExecutionPlanView {
const included = new Set(nodeIds);
return {
nodes: plan.nodes.filter((node) => included.has(node.id)),
edges: plan.edges.filter((edge) => included.has(edge.from) && included.has(edge.to))
};
}
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>
<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>;
}
function HumanActionCard({ task, action, onDecision, onExecute, onReconcile, onRefresh }: {
task: OperationalTask;
action: OperationalTaskAction;
onDecision: AgentExecutionPlanPanelProps["onDecision"];
onExecute: AgentExecutionPlanPanelProps["onExecute"];
onReconcile: AgentExecutionPlanPanelProps["onReconcile"];
onRefresh: () => void;
}) {
const [submitting, setSubmitting] = useState<"approve" | "reject" | "execute" | "reconcile" | null>(null);
const [error, setError] = useState<string | null>(null);
const [stale, setStale] = useState(false);
const [rejectComment, setRejectComment] = useState("");
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") => {
setSubmitting(kind); setError(null);
try {
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} />
{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}
<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><Button size="sm" disabled={submitting !== null} onClick={() => void run("approve")}>{submitting === "approve" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : "批准创建工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建工单"}</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown", "failed", "unsupported"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</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 createBranchLayout(plan: ExecutionPlanView) {
const ordered = topologicalNodes(plan);
if (ordered.length !== plan.nodes.length) return null;
const outgoing = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.from === node.id)]));
const incoming = new Map(plan.nodes.map((node) => [node.id, plan.edges.filter((edge) => edge.to === node.id)]));
const fork = ordered.find((node) => (outgoing.get(node.id)?.length ?? 0) > 1);
if (!fork) return { prefix: ordered, branches: [] as ExecutionNode[][], suffix: [] as ExecutionNode[] };
const starts = outgoing.get(fork.id)!.map((edge) => edge.to);
const descendants = starts.map((start) => reachableFrom(start, outgoing));
const join = ordered.find((node) => node.id !== fork.id && (incoming.get(node.id)?.length ?? 0) > 1 && descendants.every((set) => set.has(node.id)));
if (!join) return null;
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const branches: ExecutionNode[][] = [];
for (const start of starts) {
const branch: ExecutionNode[] = [];
let current = start;
const seen = new Set<string>();
while (current !== join.id && !seen.has(current)) {
seen.add(current);
const node = nodeById.get(current);
if (!node) return null;
branch.push(node);
const nextEdges = outgoing.get(current) ?? [];
if (nextEdges.length !== 1) return null;
current = nextEdges[0].to;
}
if (current !== join.id || branch.length === 0) return null;
branches.push(branch);
}
const branchIds = new Set(branches.flatMap((branch) => branch.map((node) => node.id)));
const forkIndex = ordered.findIndex((node) => node.id === fork.id);
const joinIndex = ordered.findIndex((node) => node.id === join.id);
return { prefix: ordered.slice(0, forkIndex + 1), branches, suffix: ordered.slice(joinIndex).filter((node) => !branchIds.has(node.id)) };
}
function reachableFrom(start: string, outgoing: Map<string, ExecutionEdge[]>) {
const seen = new Set<string>();
const stack = [start];
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 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 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"; }
@@ -68,6 +68,12 @@ function createConditionStepView(
runningConditionCount: number,
dataSource: WorkbenchRuntimeDataSource
): ConditionStepTickerView {
if (condition.taskId === "water-quality-complaint") {
return {
title: condition.title,
currentStep: { id: "agent-plan", title: "Agent 执行计划", detail: "打开任务查看实际节点状态与等待事项。" }
};
}
const effectiveDataSource = condition.dataSource ?? dataSource;
if (effectiveDataSource !== "mock") {
return {
@@ -1,7 +1,9 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord } from "../types";
import { ConditionDetailPanel } from "./scheduled-condition-detail-panel";
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import { ConditionDetailPanel, WorkOrderSubtasksPanel } from "./scheduled-condition-detail-panel";
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
const baseCondition: ScheduledConditionRecord = {
id: "schedule-run-1",
@@ -21,9 +23,61 @@ const baseCondition: ScheduledConditionRecord = {
modelName: "TJWater scheduler"
};
it("does not infer water-quality progress or conversation steps from elapsed time", () => {
const condition: ScheduledConditionRecord = { ...baseCondition, taskId: "water-quality-complaint", dataSource: "mock", status: "running" };
const [card] = createVisibleTickerCards([condition], 0, Date.now());
expect(card.view.progressValue).toBeUndefined();
expect(card.view.currentStep?.title).toBe("Agent 执行计划");
expect(createConditionConversationPrompt(condition)).not.toContain("工作流步骤");
});
describe("scheduled condition backend boundaries", () => {
afterEach(cleanup);
it("keeps Chinese work-order time labels in the application font", () => {
const workOrder: ScheduledWorkOrderItem = {
id: "demo-work-order",
kind: "work_order",
code: "WO-DEMO",
scheduledAt: "2026-09-23T10:00:00Z",
title: "演示预约维修",
summary: "验证中文时间标签",
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-23T10:00:00Z"),
source: "演示数据",
dispatcher: "调度岗",
priority: "normal",
dataSource: "mock",
subtasks: [{
id: "field-service",
title: "现场维修",
summary: "按预约窗口处理",
status: "pending",
scheduledAt: "2026-09-23T10:00:00Z",
location: "测试点",
assignee: "维修班组",
durationMinutes: 60,
replyWindowMinutes: 30,
stages: [{
id: "dispatch",
sequence: 1,
title: "派发",
status: "pending",
owner: "调度岗",
timeLabel: "待安排",
description: "确认预约窗口。"
}],
replyRequirements: ["回填处理结果"]
}]
};
render(<WorkOrderSubtasksPanel condition={workOrder} />);
const timeLabel = screen.getByText("待安排", { exact: true });
expect(timeLabel).toHaveClass("tabular-nums");
expect(timeLabel).not.toHaveClass("font-mono");
});
it("shows only persisted evidence for a failed backend condition", () => {
render(
<ConditionDetailPanel
@@ -57,6 +111,25 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.queryByText("演示进度")).not.toBeInTheDocument();
});
it("does not expose operational-task model, session or duration metadata", () => {
render(
<ConditionDetailPanel
condition={{
...baseCondition,
dataSource: "operational_tasks",
modelName: "TJWater operational task",
sessionId: "browser-session-1"
}}
dataSource="operational_tasks"
onContinueConversation={vi.fn()}
/>
);
expect(screen.queryByText("TJWater operational task")).not.toBeInTheDocument();
expect(screen.queryByText("browser-session-1")).not.toBeInTheDocument();
expect(screen.queryByText("未记录")).not.toBeInTheDocument();
});
it("keeps the mock work-order interaction but labels it as a local demo draft", () => {
render(
<ConditionDetailPanel
@@ -81,7 +154,7 @@ describe("scheduled condition backend boundaries", () => {
expect(screen.queryByText("系统工单已提交")).not.toBeInTheDocument();
});
it("renders an event-triggered water-quality task as a workflow before opening chat", () => {
it("does not expose the removed mock Agent execution path", () => {
const onContinueConversation = vi.fn();
render(
<ConditionDetailPanel
@@ -103,10 +176,9 @@ describe("scheduled condition backend boundaries", () => {
/>
);
expect(screen.getByText("演示执行流程 · 水质投诉调查流程")).toBeVisible();
expect(screen.getAllByText("校验上游水质监测").at(-1)).toBeVisible();
expect(screen.getAllByText("核查二次供水").at(-1)).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "与 Agent 协同" }));
expect(onContinueConversation).toHaveBeenCalledOnce();
expect(screen.getByText("后端运行状态")).toBeVisible();
expect(screen.queryByText(/演示执行流程/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "与 Agent 协同" })).not.toBeInTheDocument();
expect(onContinueConversation).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,12 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { AgentExecutionPlanPanel } from "./agent-execution-plan-panel";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type {
ExternalWorkOrderSnapshot,
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import {
@@ -105,6 +111,11 @@ type ConditionDetailPanelProps = {
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
};
export function StatusPill({
@@ -115,23 +126,24 @@ export function StatusPill({
className?: string;
}) {
const isWorkOrder = condition.kind === "work_order";
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const currentStage = isWorkOrder ? getWorkOrderCurrentStage(condition) : null;
return (
<StatusBadge
tone={
isWorkOrder
isAgentEvent ? "neutral" : isWorkOrder
? getWorkOrderStageTone(currentStage?.status)
: getStatusTone(condition.status)
}
activity={
isWorkOrder
isAgentEvent ? "static" : isWorkOrder
? getWorkOrderStageActivity(currentStage?.status)
: getStatusActivity(condition.status)
}
className={className}
>
{isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
{isAgentEvent ? "事件调查" : isWorkOrder ? currentStage?.title ?? "工单" : statusLabels[condition.status]}
</StatusBadge>
);
}
@@ -140,7 +152,12 @@ export function ConditionDetailPanel({
condition,
dataSource = "mock",
workOrderDataSource = "mock",
onContinueConversation
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
}: ConditionDetailPanelProps) {
const detailKey =
condition.kind === "work_order"
@@ -148,13 +165,21 @@ export function ConditionDetailPanel({
: condition.id;
const content =
condition.kind === "work_order" ? (
<WorkOrderDetailPanel condition={condition} dataSource={workOrderDataSource} />
<WorkOrderDetailPanel
condition={condition}
dataSource={condition.dataSource ?? workOrderDataSource}
/>
) : (
<ConditionRecordDetailPanel
condition={condition}
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={onContinueConversation}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
/>
);
@@ -178,12 +203,22 @@ function ConditionRecordDetailPanel({
condition,
dataSource,
workOrderDataSource,
onContinueConversation
onContinueConversation,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh
}: {
condition: ScheduledConditionRecord;
dataSource: WorkbenchRuntimeDataSource;
workOrderDataSource: WorkbenchWorkOrderDataSource;
onContinueConversation: () => void;
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskReconcile?: ConditionDetailPanelProps["onTaskReconcile"];
onLoadTaskEvidenceSnapshot?: ConditionDetailPanelProps["onLoadTaskEvidenceSnapshot"];
onTaskRefresh?: () => void;
}) {
const backendCondition = (condition.dataSource ?? dataSource) !== "mock";
const insight = backendCondition ? null : getConditionAnalysisInsight(condition);
@@ -191,6 +226,9 @@ function ConditionRecordDetailPanel({
const header = getConditionDetailHeader(condition);
const riskLevel = getConditionReportRiskLevel(condition.status, condition.riskLevel);
const bodyKey = `${condition.id}-${condition.status}-${condition.riskLevel}`;
const externalWorkOrder = condition.operationalTask?.work_order_snapshot?.subtasks
? toScheduledWorkOrderSnapshot(condition.operationalTask.work_order_snapshot)
: null;
function handleSubmitIncidentWorkOrder() {
const nextWorkOrder = createSubmittedIncidentWorkOrder(condition);
@@ -222,7 +260,41 @@ function ConditionRecordDetailPanel({
exit={{ opacity: 0, y: -8, scale: 0.996 }}
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
>
{condition.status === "running" ? (
{condition.operationalTask ? (
<>
{externalWorkOrder ? <WorkOrderSubtasksPanel condition={externalWorkOrder} /> : null}
{condition.operationalTask.task_type === "external_work_order"
&& !condition.operationalTask.work_order_snapshot?.subtasks ? (
<DetailSectionCard>
<SectionTitle icon={ListChecks} title="预约过程" />
<p className="mt-2 text-xs leading-5 text-slate-500">后端尚未提供工单过程,不根据总状态推测阶段。</p>
</DetailSectionCard>
) : null}
<AgentExecutionPlanPanel
task={condition.operationalTask}
onContinue={onContinueConversation}
onDecision={(actionId, version, decision, comment) => {
if (!onTaskDecision) return Promise.reject(new Error("当前未配置人工确认接口。"));
return onTaskDecision(condition.operationalTask!.task_id, actionId, version, decision, comment);
}}
onExecute={(actionId, version) => {
if (!onTaskExecute) return Promise.reject(new Error("当前未配置动作执行接口。"));
return onTaskExecute(condition.operationalTask!.task_id, actionId, version);
}}
onReconcile={(actionId, version) => {
if (!onTaskReconcile) return Promise.reject(new Error("当前未配置回执核对接口。"));
return onTaskReconcile(condition.operationalTask!.task_id, actionId, version);
}}
onLoadEvidenceSnapshot={(evidenceId) => {
if (!onLoadTaskEvidenceSnapshot) return Promise.reject(new Error("当前未配置证据快照接口。"));
return onLoadTaskEvidenceSnapshot(condition.operationalTask!.task_id, evidenceId);
}}
onRefresh={onTaskRefresh ?? (() => undefined)}
/>
</>
) : condition.taskId === "water-quality-complaint" ? (
<PersistedRunningConditionBody condition={condition} />
) : condition.status === "running" ? (
backendCondition ? (
<PersistedRunningConditionBody condition={condition} />
) : (
@@ -397,7 +469,7 @@ function WorkOrderDetailPanel({
/>
<DetailDecisionBody>
<SourceBoundaryNotice
title={dataSource === "backend" ? "后端工单记录" : "工单演示回退"}
title={dataSource === "backend" ? "后端工单记录" : "演示工单"}
description={dataSource === "backend"
? "工单的子任务、阶段、责任人和复令要求均来自后端持久化记录。"
: "这条工单来自前端演示数据,不代表真实派发、执行或复令状态。"}
@@ -513,6 +585,8 @@ function DetailHeader({
iconClassName,
iconSpin = false
}: DetailHeaderProps) {
const isAgentEvent = condition.kind === "condition" && condition.taskId === "water-quality-complaint";
const isOperationalTask = condition.kind === "condition" && condition.dataSource === "operational_tasks";
return (
<div className="px-3 py-3">
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
@@ -524,7 +598,7 @@ function DetailHeader({
iconClassName
)}
>
<Icon size={16} aria-hidden="true" className={iconSpin ? "animate-spin" : undefined} />
<Icon size={16} aria-hidden="true" className={iconSpin && !isAgentEvent ? "animate-spin" : undefined} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -538,7 +612,7 @@ function DetailHeader({
</div>
</div>
<DetailMetricGrid condition={condition} />
{!isAgentEvent && !isOperationalTask ? <DetailMetricGrid condition={condition} /> : null}
</div>
</div>
);
@@ -1201,7 +1275,7 @@ function TaskExecutionStepRow({
{step.meta.map((item) => (
<span key={`${item.label}-${item.value}`} className="contents">
<span className="font-semibold text-slate-400">{item.label}</span>
<span className={item.label === "时间" ? "font-mono text-slate-700" : "text-slate-700"}>{item.value}</span>
<span className={item.label === "时间" ? "tabular-nums text-slate-700" : "text-slate-700"}>{item.value}</span>
</span>
))}
</span>
@@ -1212,7 +1286,7 @@ function TaskExecutionStepRow({
);
}
function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
export function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderItem }) {
const currentSubtask = getWorkOrderCurrentSubtask(condition);
const completedCount = condition.subtasks.filter((subtask) =>
subtask.status === "completed" || subtask.status === "cancelled"
@@ -1303,6 +1377,55 @@ function WorkOrderSubtasksPanel({ condition }: { condition: ScheduledWorkOrderIt
);
}
function toScheduledWorkOrderSnapshot(snapshot: ExternalWorkOrderSnapshot): ScheduledWorkOrderItem {
return {
id: snapshot.work_order_id,
kind: "work_order",
code: snapshot.scenario_code ?? snapshot.work_order_id,
scheduledAt: snapshot.scheduled_at ?? snapshot.updated_at,
title: snapshot.title,
summary: snapshot.summary,
status: snapshot.status === "in_progress"
? "running"
: snapshot.status === "awaiting_reply"
? "awaiting_reply"
: snapshot.status === "completed"
? "completed"
: snapshot.status === "cancelled"
? "cancelled"
: "pending",
riskLevel: snapshot.priority === "urgent" || snapshot.priority === "high" ? "attention" : "normal",
updatedAt: Date.parse(snapshot.updated_at),
durationMinutes: snapshot.subtasks?.reduce((total, item) => total + item.duration_minutes, 0),
detail: snapshot.summary,
source: "外围工单系统",
dispatcher: "外围系统同步",
priority: snapshot.priority === "urgent" || snapshot.priority === "high" ? "urgent" : "normal",
dataSource: "backend",
subtasks: (snapshot.subtasks ?? []).map((subtask) => ({
id: subtask.id,
title: subtask.title,
summary: subtask.summary,
status: subtask.status,
scheduledAt: subtask.scheduled_at,
location: subtask.location,
assignee: subtask.assignee,
durationMinutes: subtask.duration_minutes,
replyWindowMinutes: subtask.reply_window_minutes,
stages: subtask.stages.map((stage) => ({
id: stage.id,
sequence: stage.sequence,
title: stage.title,
status: stage.status,
owner: stage.owner,
timeLabel: stage.time_label,
description: stage.description
})),
replyRequirements: subtask.reply_requirements
}))
};
}
function getWorkOrderSubtaskTone(status: ScheduledWorkOrderItem["subtasks"][number]["status"]): StatusTone {
if (status === "completed") return "success";
if (status === "running") return "info";
@@ -19,6 +19,7 @@ export const statusLabels: Record<ScheduledConditionItemStatus, string> = {
completed: "完成",
running: "执行中",
awaiting_reply: "待复令",
cancelled: "已取消",
warning: "关注",
error: "异常",
pending: "预约"
@@ -28,6 +29,7 @@ const statusTones = {
completed: "success",
running: "info",
awaiting_reply: "warning",
cancelled: "neutral",
warning: "warning",
error: "danger",
pending: "neutral"
@@ -37,6 +39,7 @@ const statusActivities = {
completed: "static",
running: "loading",
awaiting_reply: "static",
cancelled: "static",
warning: "static",
error: "static",
pending: "static"
@@ -46,6 +49,7 @@ const statusIcons: Record<ScheduledConditionItemStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
awaiting_reply: Clock3,
cancelled: XCircle,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
@@ -73,6 +77,7 @@ const conditionStatusOrder: ScheduledConditionItemStatus[] = [
"warning",
"error",
"completed",
"cancelled",
"pending"
];
@@ -1,5 +1,6 @@
import {
CalendarClock,
CalendarPlus,
ChevronDown,
ChevronRight,
ClipboardList,
@@ -17,6 +18,8 @@ import type {
WorkbenchWorkOrderDataSource
} from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import type { OperationalTaskDecision } from "../api/operational-task-client";
import type { OperationalTaskDataSnapshot } from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
@@ -26,7 +29,18 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { StatusIcon as SemanticStatusIcon } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
WORKBENCH_TASK_CATEGORIES,
getWorkbenchTaskCategory,
groupConditionsByTaskCategory,
type WorkbenchTaskCategory
} from "./task-category";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
@@ -56,12 +70,22 @@ export type ScheduledConditionFeedPresentation =
| "desktop-floating"
| "mobile-sheet";
export type ScheduleCreateRequest = {
scheduleKey: string;
title: string;
summary: string;
intervalSeconds: number;
nextRunAt: string;
};
type ScheduledConditionFeedProps = {
presentation?: ScheduledConditionFeedPresentation;
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
backendLoading?: boolean;
backendError?: string | null;
dataSource?: WorkbenchRuntimeDataSource;
workOrderDataSource?: WorkbenchWorkOrderDataSource;
visible?: boolean;
@@ -73,6 +97,13 @@ type ScheduledConditionFeedProps = {
onClose?: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string) => Promise<void>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
};
type ScheduledConditionFeedFocusRequest = {
@@ -86,6 +117,8 @@ export function ScheduledConditionFeed({
expanded: controlledExpanded,
focusRequest,
loading = false,
backendLoading = false,
backendError = null,
dataSource = "mock",
workOrderDataSource = "mock",
visible = true,
@@ -96,7 +129,14 @@ export function ScheduledConditionFeed({
onExpandAgent,
onClose,
onLoadHistorySession,
onSubmitPrompt
onSubmitPrompt,
onOpenTaskCollaboration,
onTaskDecision,
onTaskExecute,
onTaskReconcile,
onLoadTaskEvidenceSnapshot,
onTaskRefresh,
onCreateSchedule
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<
@@ -105,27 +145,46 @@ export function ScheduledConditionFeed({
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] =
useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const [taskCategory, setTaskCategory] = useState<WorkbenchTaskCategory>("scheduled");
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleTitle, setScheduleTitle] = useState("");
const [scheduleKey, setScheduleKey] = useState("scada-diagnosis");
const [scheduleSummary, setScheduleSummary] = useState("");
const [scheduleIntervalMinutes, setScheduleIntervalMinutes] = useState("15");
const [scheduleIntervalPreset, setScheduleIntervalPreset] = useState("15");
const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000)));
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null);
const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock;
const desktopFloating = presentation === "desktop-floating";
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
const conditionsByCategory = useMemo(
() => groupConditionsByTaskCategory(conditions),
[conditions]
);
const categoryConditions = conditionsByCategory[taskCategory];
const activeLoading = taskCategory === "scheduled" ? loading : backendLoading;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(categoryConditions),
[categoryConditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilterOptions = useMemo(
() => createTaskFilterOptions(categoryConditions),
[categoryConditions]
);
const taskFilteredConditions = useMemo(
() =>
conditions.filter(
categoryConditions.filter(
(condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter
),
[conditions, taskFilter]
[categoryConditions, taskFilter]
);
const statusFilterOptions = useMemo(
() => createStatusFilterOptions(taskFilteredConditions),
@@ -150,24 +209,9 @@ export function ScheduledConditionFeed({
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const conditionSourceLabel = {
mock: "工况演示数据",
backend: "后端当天数据",
scheduled_tasks: "后端定时任务"
}[dataSource];
const workOrderSourceLabel = {
mock: "工单演示回退",
backend: "后端工单",
none: "未接入工单"
}[workOrderDataSource];
const sourceLabel = `${conditionSourceLabel} · ${workOrderSourceLabel}`;
const headerSummary = loading
const headerSummary = loading && backendLoading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条 · ${sourceLabel}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条 · ${sourceLabel}`;
: `定时 ${conditionsByCategory.scheduled.length} · 调查 ${conditionsByCategory.investigation.length} · 预约 ${conditionsByCategory.appointment.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
@@ -222,20 +266,24 @@ export function ScheduledConditionFeed({
return;
}
const targetCondition = allTimelineConditions.find(
const targetCondition = conditions.find(
(condition) => condition.id === focusConditionId
);
if (!targetCondition) {
return;
}
const targetCategory = getWorkbenchTaskCategory(targetCondition);
if (!targetCategory) return;
setTaskCategory(targetCategory);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [
allTimelineConditions,
conditions,
focusConditionId,
focusRequestId,
onFocusRequestHandled,
@@ -250,6 +298,42 @@ export function ScheduledConditionFeed({
);
}
function handleTaskCategoryChange(nextCategory: WorkbenchTaskCategory) {
setTaskCategory(nextCategory);
if (nextCategory !== "scheduled") setScheduleOpen(false);
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(conditionsByCategory[nextCategory][0]?.id ?? null);
}
function handleToggleSchedule() {
if (!scheduleOpen) {
setTaskCategory("scheduled");
updateExpanded(true);
}
setScheduleOpen((current) => !current);
}
async function handleCreateSchedule() {
const title = scheduleTitle.trim();
const summary = scheduleSummary.trim();
const key = scheduleKey.trim();
const intervalMinutes = Number(scheduleIntervalMinutes);
const nextRunAt = new Date(scheduleNextRunAt);
if (!title || !summary || !key) { setScheduleError("请填写名称、流程和任务说明。"); return; }
if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { setScheduleError("执行周期不能少于 1 分钟。"); return; }
if (Number.isNaN(nextRunAt.getTime())) { setScheduleError("请选择有效的下次执行时间。"); return; }
if (!onCreateSchedule) { setScheduleError("当前未连接定时流程配置接口。"); return; }
setScheduleSubmitting(true); setScheduleError(null);
try {
await onCreateSchedule({ scheduleKey: key, title, summary, intervalSeconds: intervalMinutes * 60, nextRunAt: nextRunAt.toISOString() });
setScheduleOpen(false); setScheduleTitle(""); setScheduleSummary("");
showMapNotice({ tone: "success", title: "定时任务已添加", message: "仅保存在当前演示工作台,不会提交后端调度。" });
} catch (error) {
setScheduleError(error instanceof Error ? error.message : "定时流程创建失败。");
} finally { setScheduleSubmitting(false); }
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
@@ -262,6 +346,18 @@ export function ScheduledConditionFeed({
return;
}
if (condition.operationalTask) {
onExpandAgent();
void Promise.resolve(onOpenTaskCollaboration?.(condition.operationalTask.task_id))
.catch((error) => showMapNotice({
id: "operational-task-collaboration",
tone: "error",
title: "Agent 协作打开失败",
message: error instanceof Error ? error.message : "无法打开这条任务的协作问答。"
}));
return;
}
onExpandAgent();
if (!condition.sessionId) {
@@ -346,6 +442,17 @@ export function ScheduledConditionFeed({
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
{onCreateSchedule ? (
<button
type="button"
aria-label="配置定时流程"
aria-expanded={scheduleOpen}
onClick={handleToggleSchedule}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<CalendarPlus size={17} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-expanded={expanded}
@@ -375,6 +482,38 @@ export function ScheduledConditionFeed({
) : null}
</header>
{expanded ? (
<TaskCategorySwitch
activeCategory={taskCategory}
counts={{
scheduled: conditionsByCategory.scheduled.length,
investigation: conditionsByCategory.investigation.length,
appointment: conditionsByCategory.appointment.length
}}
onChange={handleTaskCategoryChange}
/>
) : null}
{scheduleOpen ? (
<section aria-label="配置定时流程" className={cn("mt-2 p-3", CONDITION_CONTENT_SURFACE_CLASS_NAME)}>
<label className="block text-xs font-medium text-slate-600">定时流程名称<Input className="mt-1 h-9" placeholder="例如夜间低压巡检" value={scheduleTitle} onChange={(event) => setScheduleTitle(event.target.value)} /></label>
<div className="mt-2"><FieldSelect label="执行流程" value={scheduleKey} onValueChange={setScheduleKey} options={[{ value: "scada-diagnosis", label: "SCADA 数据诊断" }, { value: "smart-dispatch", label: "管网智能调度" }, { value: "network-simulation", label: "管网定时模拟" }, { value: "pump-energy", label: "泵站能耗检查" }]} /></div>
<label className="mt-2 block text-xs font-medium text-slate-600">任务说明<Textarea className="mt-1 min-h-16" placeholder="说明本流程核查什么以及预期输出" value={scheduleSummary} onChange={(event) => setScheduleSummary(event.target.value)} /></label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<FieldSelect label="执行周期" value={scheduleIntervalPreset} onValueChange={(value) => { setScheduleIntervalPreset(value); if (value !== "custom") setScheduleIntervalMinutes(value); }} options={[{ value: "5", label: "每 5 分钟" }, { value: "15", label: "每 15 分钟" }, { value: "30", label: "每 30 分钟" }, { value: "60", label: "每 60 分钟" }, { value: "custom", label: "自定义周期" }]} />
{scheduleIntervalPreset === "custom" ? <label className="mt-2 block text-xs font-medium text-slate-600">自定义分钟数<Input className="mt-1 h-9" inputMode="numeric" value={scheduleIntervalMinutes} onChange={(event) => setScheduleIntervalMinutes(event.target.value.replace(/\D/g, ""))} /></label> : null}
</div>
<DateTimeField label="下次执行时间" value={scheduleNextRunAt} onChange={setScheduleNextRunAt} />
</div>
{scheduleError ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{scheduleError}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="outline" disabled={scheduleSubmitting} onClick={() => setScheduleOpen(false)}>取消</Button>
<Button type="button" size="sm" disabled={scheduleSubmitting} onClick={() => void handleCreateSchedule()}>{scheduleSubmitting ? "创建中" : "创建定时流程"}</Button>
</div>
</section>
) : null}
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
@@ -398,7 +537,7 @@ export function ScheduledConditionFeed({
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
{activeLoading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
@@ -406,6 +545,11 @@ export function ScheduledConditionFeed({
dataSource={dataSource}
workOrderDataSource={workOrderDataSource}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
onTaskDecision={onTaskDecision}
onTaskExecute={onTaskExecute}
onTaskReconcile={onTaskReconcile}
onLoadTaskEvidenceSnapshot={onLoadTaskEvidenceSnapshot}
onTaskRefresh={onTaskRefresh}
/>
) : null}
</div>
@@ -418,7 +562,17 @@ export function ScheduledConditionFeed({
<ConditionTimelinePanel
expanded={expanded}
presentation={presentation}
loading={loading}
loading={activeLoading}
emptyTitle={
backendError && taskCategory !== "scheduled"
? `${WORKBENCH_TASK_CATEGORIES.find((item) => item.id === taskCategory)?.label ?? "后端任务"}暂不可用`
: undefined
}
emptyDescription={
backendError && taskCategory !== "scheduled"
? backendError
: undefined
}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
taskFilter={taskFilter}
@@ -443,6 +597,54 @@ export function ScheduledConditionFeed({
);
}
function TaskCategorySwitch({
activeCategory,
counts,
onChange
}: {
activeCategory: WorkbenchTaskCategory;
counts: Record<WorkbenchTaskCategory, number>;
onChange: (category: WorkbenchTaskCategory) => void;
}) {
return (
<nav
aria-label="工况任务分类"
className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mt-2 grid grid-cols-3 gap-1 p-1")}
>
{WORKBENCH_TASK_CATEGORIES.map((category) => {
const active = category.id === activeCategory;
return (
<button
key={category.id}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onChange(category.id)}
className={cn(
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200",
active
? "bg-white text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-800"
)}
>
<span className="flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-semibold">{category.label}</span>
<span className="shrink-0 text-xs tabular-nums text-slate-400">{counts[category.id]}</span>
</span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-slate-400">
{category.sourceLabel}
</span>
</button>
);
})}
</nav>
);
}
function formatLocalDateTimeInput(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
presentation: ScheduledConditionFeedPresentation;
@@ -457,6 +659,8 @@ type ConditionTimelinePanelProps = {
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
emptyTitle?: string;
emptyDescription?: string;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
@@ -477,6 +681,8 @@ function ConditionTimelinePanel({
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
emptyTitle,
emptyDescription,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
@@ -545,11 +751,11 @@ function ConditionTimelinePanel({
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
title={emptyTitle ?? (filterActive ? "没有符合筛选的任务" : "暂无任务")}
description={
filterActive
? "调整任务或状态筛选后查看其他工况记录。"
: "新的工况任务完成后会出现在这里。"
emptyDescription ?? (filterActive
? "调整任务或状态筛选后查看其他记录。"
: "新任务进入当前分类后会出现在这里。")
}
/>
)}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { OperationalTask } from "../api/operational-task-client";
import type { ScheduledConditionItem, ScheduledConditionRecord } from "../types";
import {
getWorkbenchTaskCategory,
groupConditionsByTaskCategory
} from "./task-category";
function condition(
id: string,
taskType?: OperationalTask["task_type"]
): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId: taskType ? "operational-task" : "scada-diagnosis",
sessionId: id,
scheduledAt: "2026-09-23T09:00:00+08:00",
title: id,
summary: id,
status: "pending",
riskLevel: "normal",
updatedAt: Date.parse("2026-09-23T09:00:00+08:00"),
operationalTask: taskType ? ({ task_type: taskType } as OperationalTask) : undefined
};
}
describe("workbench task categories", () => {
it("keeps scheduled mock tasks separate from backend investigations and appointments", () => {
const scheduled = condition("scheduled");
const investigation = condition("investigation", "event_investigation");
const appointment = condition("appointment", "external_work_order");
const projectedComplaint = {
...condition("projected-complaint", "external_work_order"),
operationalTask: {
...condition("projected-complaint", "external_work_order").operationalTask!,
source: { kind: "external_system", external_id: "wo-1", external_revision: 1, event_kind: "water_quality_complaint" }
}
} as ScheduledConditionRecord;
const backendSchedule = condition("backend-schedule", "scheduled_flow");
const demoWorkOrder = { kind: "work_order", id: "demo" } as ScheduledConditionItem;
expect(getWorkbenchTaskCategory(scheduled)).toBe("scheduled");
expect(getWorkbenchTaskCategory(investigation)).toBe("investigation");
expect(getWorkbenchTaskCategory(appointment)).toBe("appointment");
expect(getWorkbenchTaskCategory(projectedComplaint)).toBeNull();
expect(getWorkbenchTaskCategory(backendSchedule)).toBeNull();
expect(getWorkbenchTaskCategory(demoWorkOrder)).toBe("appointment");
expect(groupConditionsByTaskCategory([
scheduled,
investigation,
appointment,
projectedComplaint,
backendSchedule,
demoWorkOrder
])).toEqual({
scheduled: [scheduled],
investigation: [investigation],
appointment: [appointment, demoWorkOrder]
});
});
});
@@ -0,0 +1,63 @@
import type { ScheduledConditionItem } from "../types";
export type WorkbenchTaskCategory = "scheduled" | "investigation" | "appointment";
export const WORKBENCH_TASK_CATEGORIES = [
{
id: "scheduled",
label: "定时任务",
sourceLabel: "模拟数据"
},
{
id: "investigation",
label: "调查任务",
sourceLabel: "后端"
},
{
id: "appointment",
label: "预约任务",
sourceLabel: "后端 / 演示"
}
] as const satisfies readonly {
id: WorkbenchTaskCategory;
label: string;
sourceLabel: string;
}[];
export function getWorkbenchTaskCategory(
condition: ScheduledConditionItem
): WorkbenchTaskCategory | null {
if (condition.kind === "work_order") return "appointment";
const taskType = condition.operationalTask?.task_type;
if (taskType === "event_investigation") return "investigation";
if (taskType === "external_work_order") {
const eventKind = condition.operationalTask?.source?.event_kind;
const workOrderType = condition.operationalTask?.work_order_snapshot?.work_order_type;
if (
eventKind === "water_quality_complaint"
|| eventKind === "burst_report"
|| workOrderType === "water_quality_complaint"
|| workOrderType === "burst_report"
) return null;
return "appointment";
}
if (taskType) return null;
return "scheduled";
}
export function groupConditionsByTaskCategory(conditions: ScheduledConditionItem[]) {
const grouped: Record<WorkbenchTaskCategory, ScheduledConditionItem[]> = {
scheduled: [],
investigation: [],
appointment: []
};
conditions.forEach((condition) => {
const category = getWorkbenchTaskCategory(condition);
if (category) grouped[category].push(condition);
});
return grouped;
}