feat(workbench): redesign investigation reports and diagrams

This commit is contained in:
2026-10-10 16:02:47 +08:00
parent 85a9a3ab45
commit ba78a24384
27 changed files with 2380 additions and 187 deletions
@@ -0,0 +1,634 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, History, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskEvidence,
OperationalTaskReport,
OperationalTaskReportPage
} from "../api/operational-task-client";
import {
investigationDiagram,
investigationStatus,
isPendingInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
};
const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const roundLabel = (snapshot: OperationalTaskReport) =>
snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`;
const actionTitle = (action: OperationalTaskAction) =>
String(action.payload.title ?? (action.kind === "issue_command" ? "设备命令" : "工单草稿"));
const actionProgressLabel = (action: OperationalTaskAction) => {
if (action.approval_status === "rejected") return "已拒绝";
if (action.approval_status === "pending") return "待审核";
return {
not_submitted: "待执行",
submitted: "已提交",
succeeded: action.kind === "create_work_order" ? "工单已创建" : "执行成功",
failed: "执行失败",
unsupported: "无法执行",
unknown: "待核对回执"
}[action.execution_status];
};
type InvestigationTimelineItem = {
id: string;
occurredAt: string;
title: string;
detail: string;
};
function investigationTimeline(
task: OperationalTask,
snapshot: OperationalTaskReport | null
): InvestigationTimelineItem[] {
const items: InvestigationTimelineItem[] = task.evidence.map((evidence) => ({
id: `evidence:${evidence.evidence_id}`,
occurredAt: evidence.recorded_at,
title: evidence.kind === "human_input" ? "已记录人工事实" : "已保存调查资料",
detail: evidence.summary
}));
if (snapshot) {
items.push({
id: `report:${snapshot.revision}`,
occurredAt: snapshot.published_at,
title: `${roundLabel(snapshot)}报告第 ${snapshot.revision} 版已发布`,
detail: snapshot.report.current_conclusion
});
}
task.actions.forEach((action) => {
const title = actionTitle(action);
items.push({
id: `action:${action.action_id}:proposed`,
occurredAt: action.proposed_at,
title: `${title}已提出`,
detail: action.reason
});
if (action.edited_at) {
items.push({
id: `action:${action.action_id}:edited`,
occurredAt: action.edited_at,
title: `${title}草稿已更新`,
detail: "后续审核与执行以更新后的版本为准。"
});
}
if (action.decided_at && action.approval_status !== "pending") {
items.push({
id: `action:${action.action_id}:decided`,
occurredAt: action.decided_at,
title: `${title}${action.approval_status === "approved" ? "已批准" : "已拒绝"}`,
detail: action.decision_comment ?? "已记录人工审核结果。"
});
}
if (action.receipt) {
items.push({
id: `action:${action.action_id}:receipt`,
occurredAt:
action.receipt.completed_at ?? action.executed_at ?? action.receipt.submitted_at,
title: `${title}已有执行回执`,
detail: action.receipt.detail
});
} else if (action.executed_at) {
items.push({
id: `action:${action.action_id}:executed`,
occurredAt: action.executed_at,
title: `${title}${actionProgressLabel(action)}`,
detail: action.execution_detail ?? "已记录动作执行状态。"
});
}
});
return items.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt));
}
export function InvestigationTaskReport({
task,
onContinue,
renderAction,
renderEvidence,
onLoadReports,
onReportViewed,
focusActionId
}: Props) {
const [selection, setSelection] = useState<ReportSelection | null>(() =>
task.latest_report
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
: null
);
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const snapshot = selection?.snapshot ?? null;
const report = snapshot?.report;
const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null));
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision));
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3);
const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3);
const timeline = investigationTimeline(task, snapshot);
useEffect(() => {
if (!selection && task.latest_report)
setSelection({
snapshot: task.latest_report,
diagram: investigationDiagram(task, task.latest_report)
});
}, [selection, task]);
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
onReportViewed(snapshot.execution_generation, snapshot.revision);
}, [snapshot, onReportViewed]);
useEffect(() => {
if (!focusActionId) return;
const details = [
...document.querySelectorAll<HTMLDetailsElement>("details[data-review-action]")
].find((item) => item.dataset.reviewAction === focusActionId);
if (details) {
details.open = true;
details.scrollIntoView?.({ block: "nearest" });
details.querySelector<HTMLElement>("summary")?.focus();
}
}, [focusActionId]);
const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) =>
onContinue({
prompt,
context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra }
});
const selectReport = (item: OperationalTaskReport) =>
setSelection({ snapshot: item, diagram: investigationDiagram(task, item) });
const loadHistory = async (offset: number) => {
if (!onLoadReports || historyLoading) return;
setHistoryLoading(true);
setHistoryError(null);
try {
const page = await onLoadReports(offset);
setHistory((current) => (offset === 0 ? page.items : [...current, ...page.items]));
setHistoryOffset(page.next_offset);
} catch (error) {
setHistoryError(error instanceof Error ? error.message : "报告版本加载失败");
} finally {
setHistoryLoading(false);
}
};
return (
<article aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
调查任务 · 第 {task.execution_generation} 轮
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold leading-6 text-[var(--ink)]">{task.title}</h2>
<StatusBadge
tone={
task.lifecycle_status === "failed"
? "danger"
: currentInsufficient || pendingActions.length
? "warning"
: "neutral"
}
>
{investigationStatus(task)}
</StatusBadge>
</div>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{task.summary}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
}
>
<MessageSquare size={14} aria-hidden />
Agent 协作
</Button>
</header>
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
查看新报告
</Button>
</div>
) : null}
<section aria-label="任务报告" className="investigation-report-section">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<FileText size={15} aria-hidden />
当前结果
</h3>
{report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
? "证据不足"
: report.outcome === "concluded"
? "已形成判断"
: "结果未记录"}
</StatusBadge>
) : null}
</div>
{report && snapshot ? (
<>
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--ink)]">
{previousRound
? task.status === "queued" || task.status === "running"
? "上一轮报告,本轮分析中"
: "上一轮报告"
: olderRevision
? "本轮历史报告"
: insufficient
? task.lifecycle_status === "completed"
? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" || task.lifecycle_status === "cancelled"
? "无法形成结论 · 本轮运行已停止"
: "无法形成结论 · 正在结束本轮"
: report.stage === "interim"
? "阶段性判断"
: report.outcome === "concluded"
? "调查结论"
: "历史报告内容"}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{report.conclusion_boundary}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{report.stop_reason}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
{!insufficient &&
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
<Disclosure label="判断说明与建议" className="mt-2">
{report.recommendations.length > 0 ? (
<ReportItems title="处置建议" items={report.recommendations} />
) : null}
{report.pending_items.length > 0 ? (
<ReportItems title="待核实问题" items={report.pending_items} />
) : null}
</Disclosure>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</section>
{pendingActions.length > 0 ? (
<section aria-label="当前需处理" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
当前需处理{" "}
<span className="font-normal text-[var(--ink-muted)]">
· {pendingActions.length} 项
</span>
</h3>
{previousRound || olderRevision ? (
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
下列操作使用当前草稿与审批状态。
</p>
) : null}
{pendingActions.map((action) => (
<Disclosure
key={action.action_id}
data-review-action={action.action_id}
initiallyOpen={action.action_id === focusActionId}
label={
<span>
{actionTitle(action)} ·{" "}
{action.approval_status === "pending"
? "待审核"
: action.execution_status === "not_submitted"
? "待执行"
: "待核对回执"}
</span>
}
>
{renderAction(action)}
</Disclosure>
))}
</section>
) : null}
{completedActions.length > 0 ? (
<section aria-label="处置进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
处置进展{" "}
<span className="font-normal text-[var(--ink-muted)]">
· {completedActions.length} 项
</span>
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{previousRound || olderRevision
? "以下为任务当前动作状态,与所选历史报告分开记录。"
: "动作回执只说明对应系统操作结果,不替代后续现场事实。"}
</p>
{completedActions.map((action) => (
<Disclosure
key={action.action_id}
label={
<span className="investigation-action-summary">
<span>{actionTitle(action)} · {actionProgressLabel(action)}</span>
<span className="investigation-action-summary-detail">
{action.receipt?.detail ?? action.execution_detail ?? action.reason}
</span>
</span>
}
>
<div className="pb-2">{renderAction(action)}</div>
</Disclosure>
))}
</section>
) : null}
{report?.data_gaps?.length ? (
<section aria-label="数据缺口" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">
{insufficient ? "关键数据缺口" : "数据范围限制"}
</h3>
{report.data_gaps.map((gap) => (
<div key={gap.gap_id} className="mt-3">
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4">
<Disclosure label="查看查询依据">
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
<p>查询范围:{gap.scope}</p>
<p>需补齐:{gap.required_data}</p>
{gap.evidence_refs.map((id) => (
<EvidenceReference
key={id}
id={id}
task={task}
renderEvidence={renderEvidence}
/>
))}
</div>
</Disclosure>
<Button
variant="outline"
size="sm"
onClick={() =>
discuss(
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
>
补充事实
</Button>
</div>
</div>
))}
{insufficient ? (
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
{previousRound || olderRevision
? "此处展示所选历史报告的数据缺口,当前调查状态见顶部。"
: "补齐事实后发起新一轮调查。原投诉工单与现场处置进度独立记录。"}
</p>
) : null}
</section>
) : report?.data_gaps == null && report ? (
<p className="px-4 py-2 text-xs text-[var(--ink-muted)]">历史记录未提供结构化数据缺口。</p>
) : null}
{diagram ? (
<InvestigationDiagramView
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
diagram={diagram}
renderDetails={(node) => (
<>
{node.evidence_refs.map((id) => (
<EvidenceReference key={id} id={id} task={task} renderEvidence={renderEvidence} />
))}
{node.action_refs.map((id) => {
const action = task.actions.find((item) => item.action_id === id);
return action ? (
<p key={id} className="text-xs leading-5 text-[var(--ink-muted)]">
关联动作:{actionTitle(action)} ·{" "}
{action.approval_status === "approved"
? "已批准"
: action.approval_status === "rejected"
? "已拒绝"
: "待审核"}{" "}
·{" "}
{action.execution_status === "succeeded"
? "已有执行回执"
: "当前执行结果未确认成功"}
</p>
) : null;
})}
{!node.evidence_refs.length && !node.action_refs.length ? (
<p className="text-xs leading-5 text-[var(--ink-muted)]">
此处说明核查方向,尚无关联证据或业务动作记录。
</p>
) : null}
</>
)}
onAsk={(node) =>
discuss(
`请解释调查脉络中的“${node.label}”及其关联依据。`,
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
)
}
/>
) : null}
<section aria-label="主要依据" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
<button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
>
围绕此证据提问
</button>
</div>
))}
{(snapshot ? evidence : task.evidence).length === 0 ? (
<p className="mt-2 text-sm text-[var(--ink-muted)]">尚无可展示的引用证据。</p>
) : null}
{remainingEvidence.length > 0 ? (
<Disclosure label={`更多引用依据 · ${remainingEvidence.length} 条`} className="mt-2">
{remainingEvidence.map((item) => (
<div key={item.evidence_id}>
{renderEvidence(item)}
<button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
>
围绕此证据提问
</button>
</div>
))}
</Disclosure>
) : null}
{snapshot &&
task.evidence.some((item) => !report?.evidence_refs.includes(item.evidence_id)) ? (
<Disclosure label="其他已保存资料" className="mt-2">
{task.evidence
.filter((item) => !report?.evidence_refs.includes(item.evidence_id))
.map(renderEvidence)}
</Disclosure>
) : null}
</section>
<section className="investigation-report-section">
<Disclosure label="任务时间线">
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{timeline.map((item) => (
<li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
{dateLabel(item.occurredAt)}
</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
</li>
))}
</ol>
</Disclosure>
{onLoadReports ? (
<Disclosure
label={
<span className="flex items-center gap-2">
<History size={14} aria-hidden />
报告版本
</span>
}
onToggle={(event) => {
if (event.currentTarget.open && history.length === 0 && !historyLoading)
void loadHistory(0);
}}
>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => selectReport(item)}
>
<span>
{roundLabel(item)} · 报告第 {item.revision} 版
{item.revision === task.latest_report?.revision ? " · 最新版" : ""}
</span>
<span className="text-xs text-[var(--ink-muted)]">
{item.report.outcome === "insufficient_data"
? "证据不足"
: item.report.stage === "interim"
? "阶段性判断"
: item.report.outcome === "concluded"
? "已形成判断"
: "历史记录"}
</span>
</button>
))}
{historyError ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-warning-foreground)]">
{historyError}
</p>
) : null}
{historyLoading ? (
<p className="py-2 text-xs text-[var(--ink-muted)]">正在读取报告版本。</p>
) : historyOffset != null || historyError ? (
<Button
variant="outline"
size="sm"
onClick={() => void loadHistory(historyError ? 0 : historyOffset!)}
>
{historyError ? "重新加载" : "更多报告"}
</Button>
) : null}
</Disclosure>
) : null}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</section>
</article>
);
}
function EvidenceReference({
id,
task,
renderEvidence
}: {
id: string;
task: OperationalTask;
renderEvidence: Props["renderEvidence"];
}) {
const item = task.evidence.find((evidence) => evidence.evidence_id === id);
return item ? (
renderEvidence(item)
) : (
<p className="text-xs text-[var(--ink-muted)]">关联证据当前不可用。</p>
);
}
function ReportItems({ title, items }: { title: string; items: string[] }) {
return (
<div className="py-2 text-sm leading-6 text-[var(--ink)]">
<p className="font-medium">{title}</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}