Files
next-tjwater-frontend/src/features/workbench/components/investigation-task-report.tsx
T
jiang a617aa95e9 fix(workbench): stabilize investigation presentation rendering
Keep investigation graphs stable during updates, defer expensive chart rendering, and prevent stale results when switching tasks. Adopt reference annotations and consolidate frontend documentation.
2026-11-11 19:10:41 +08:00

752 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getReportPresentationState } from "../utils/report-presentation-state";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime as dateLabel } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { InvestigationDiagram, InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
OperationalTaskEvidence,
OperationalTaskReport,
OperationalTaskReportPage,
TaskExecutionHistoryTarget
} from "../api/operational-task-client";
import {
investigationDiagram,
investigationStatus,
isPendingInvestigationAction,
isRetryableInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import { InvestigationReportNavigation, type InvestigationReportSection } from "./investigation-report-navigation";
import { showMapNotice } from "@/features/map/core";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
task: OperationalTask;
onContinue: (discussion?: InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
renderAction: (action: OperationalTaskAction) => ReactNode;
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
};
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.questions?.forEach((question) => {
items.push({ id: `question:${question.question_request_id}`, occurredAt: question.created_at,
title: "调查提出补充问题", detail: question.questions.map((item) => item.title).join(";") });
if (question.answered_at) items.push({ id: `answer:${question.question_request_id}`, occurredAt: question.answered_at,
title: "已收到调查回答", detail: "回答已保存为用户补充信息,调查接续处理。" });
});
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,
onViewExecutionHistory,
renderAction,
renderEvidence,
onLoadReports,
onAnswerQuestion,
onReportViewed,
focusActionId,
onLocate
}: Props) {
const questionsRef = useRef<HTMLElement>(null);
const hasPendingQuestions = task.questions?.some((request) => request.status === "pending"
&& request.execution_generation === task.execution_generation) ?? false;
// Null follows live task content. Only a deliberate history selection freezes a report.
const [selection, setSelection] = useState<ReportSelection | null>(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 [openingExecutionHistory, setOpeningExecutionHistory] = useState(false);
const live = selection === null;
const snapshot = selection?.snapshot ?? task.latest_report;
const displayedGeneration = live ? task.execution_generation : snapshot?.execution_generation;
const fallbackExecutionRun = task.latest_run ?? task.active_run;
const executionHistoryTarget: TaskExecutionHistoryTarget | null = snapshot ? {
executionGeneration: snapshot.execution_generation ?? null,
sessionId: snapshot.source_session_id
} : live && fallbackExecutionRun?.agent_session_id ? {
executionGeneration: fallbackExecutionRun.generation,
sessionId: fallbackExecutionRun.agent_session_id
} : null;
const knownExecutionRun = executionHistoryTarget
? [task.active_run, task.latest_run].find((run) => run
&& (executionHistoryTarget.executionGeneration == null
|| run.generation === executionHistoryTarget.executionGeneration)
&& run.agent_session_id === executionHistoryTarget.sessionId) ?? null
: null;
const report = snapshot?.report;
const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null;
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
const savedDiagram = task.latest_progress?.progress.diagram;
const cumulativeDiagram = savedDiagram?.edges != null ? savedDiagram : null;
const diagram = live
? cumulativeDiagram ?? (progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot))
: selection.diagram;
const latestAvailable = !live;
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
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);
const reportRef = useReportUpdateMotion(task.task_id,
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
const sections: InvestigationReportSection[] = [
"任务报告",
...(report?.ui_blocks?.length ? ["交互分析" as const] : []),
...(live ? ["当前调查进展" as const] : []),
...(pendingActions.length ? ["当前需处理" as const] : []),
...(completedActions.length ? ["处置进展" as const] : []),
...(report?.data_gaps?.length ? ["数据缺口" as const] : []),
...(diagram ? ["调查脉络" as const] : []),
"主要依据", "任务时间线",
...(onLoadReports ? ["报告版本" as const] : []),
];
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,
reportGeneration: snapshot?.execution_generation,
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);
}
};
const openExecutionHistory = async () => {
if (!onViewExecutionHistory || !executionHistoryTarget || openingExecutionHistory) return;
setOpeningExecutionHistory(true);
try {
if (knownExecutionRun) {
await onViewExecutionHistory(executionHistoryTarget, knownExecutionRun.run_id);
} else {
await onViewExecutionHistory(executionHistoryTarget);
}
} catch (error) {
showMapNotice({
tone: "error",
title: "调查执行记录打开失败",
message: error instanceof Error ? error.message : "请稍后重试。"
});
} finally {
setOpeningExecutionHistory(false);
}
};
return (
<ExpandableReading title={snapshot?.report.title ?? task.title}
summary={<p>{snapshot?.report.current_conclusion ?? task.summary}</p>}>
<article ref={reportRef} 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)]">
{displayedGeneration == null ? "调查任务 · 历史轮次" : `调查任务 · 第 ${displayedGeneration} 轮`}
</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>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!onViewExecutionHistory || !executionHistoryTarget || openingExecutionHistory}
title={onViewExecutionHistory && executionHistoryTarget ? "在左侧 Agent 中打开所选轮次自动同步的调查执行记录,并可创建调试会话。" : "所选轮次还没有可查看的执行记录。"}
onClick={() => void openExecutionHistory()}
>
{openingExecutionHistory
? <LoaderCircle size={14} className="animate-spin" aria-hidden />
: <History size={14} aria-hidden />}
执行记录
</Button>
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
Agent 协作
</Button>
</div>
</header>
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
<Button variant="outline" size="sm" onClick={() => setSelection(null)}>
返回实时内容
</Button>
</div>
) : null}
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`}
headingAccessory={report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
? "证据不足"
: report.outcome === "concluded"
? "已形成判断"
: "结果未记录"}
</StatusBadge>
) : null}
preview={<>
{report && snapshot ? (
<>
{previousRound || olderRevision || insufficient || report.stage !== "final" || report.outcome !== "concluded" ? (
<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"
? "阶段性判断"
: "历史报告内容"}
</p>
) : null}
<p className="investigation-conclusion-preview mt-2 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{formatInvestigationText(report.conclusion_boundary)}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{formatInvestigationText(report.stop_reason)}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</>}>
{report && snapshot ? <>
<p className="text-xs text-[var(--ink-muted)]">{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 · {dateLabel(snapshot.published_at)}</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
{insufficient ? <p className="mt-3 text-sm leading-6 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : <>
{report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{report.pending_items.length > 0 ? <ReportItems title="待核实问题" items={report.pending_items} /> : null}
</>}
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
</InvestigationExpandCard>
{snapshot ? <InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} onLocate={onLocate} /> : null}
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
{progress ? <p className="mt-1 text-xs text-[var(--ink-muted)]">实时内容 · 第 {progress.execution_generation} 轮 · 更新 {dateLabel(progress.published_at)}</p> : null}
</section> : null}
{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"
? "待审核"
: isRetryableInvestigationAction(action)
? "待重试"
: 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)]">{formatInvestigationText(gap.missing_data)}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{formatInvestigationText(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>查询范围:{formatInvestigationText(gap.scope)}</p>
<p>需补齐:{formatInvestigationText(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={() => {
if (hasPendingQuestions) {
questionsRef.current?.scrollIntoView({ block: "start" });
questionsRef.current?.focus({ preventScroll: true });
} else {
discuss(
`请帮我讨论“${formatInvestigationText(gap.missing_data)}”这个缺口的查询范围和补齐途径。`,
{ gap_id: gap.gap_id }
);
}
}}
>
{hasPendingQuestions ? "回答当前调查问题" : "讨论此缺口"}
</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={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
diagram={diagram}
onLocate={onLocate}
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}
</>
)}
/>
) : null}
<InvestigationExpandCard title="主要依据" disabled={task.evidence.length === 0} preview={<>
<div className="investigation-evidence-preview">
{majorEvidence.map((item) => <div key={item.evidence_id}>
<p className="text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(item.summary)}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.source_label} · {dateLabel(item.observed_at ?? item.recorded_at)}</p>
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={() => discuss(
`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, { evidence_id: item.evidence_id }
)}><MessageSquare size={14} aria-hidden />围绕此证据提问</Button>
</div>)}
</div>
<p className="mt-2 text-xs text-[var(--ink-muted)]">
{(snapshot ? evidence : task.evidence).length > 0
? `共 ${(snapshot ? evidence : task.evidence).length} 条引用依据,展开查看来源与原始记录。`
: "尚无可展示的引用证据。"}
</p>
</>}>
{(close) => <>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
{onLocate && diagram?.node_refs.some((node) => node.evidence_refs.includes(item.evidence_id) && node.map_targets?.length) ? (
<button type="button" className="investigation-text-action" onClick={() => { close(false); onLocate(diagram.node_refs.filter((node) => node.evidence_refs.includes(item.evidence_id)).flatMap((node) => node.map_targets ?? [])); }}>在地图查看</button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</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"
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”的来源和判断影响。`, {
evidence_id: item.evidence_id
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</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}
</>}
</InvestigationExpandCard>
<InvestigationExpandCard title="任务时间线" disabled={timeline.length === 0} preview={
<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{timeline[0] ? `${timeline[0].title} · ${dateLabel(timeline[0].occurredAt)},共 ${timeline.length} 条记录。` : "尚无调查记录。"}
</p>
}>
<InvestigationTimeline items={timeline} />
</InvestigationExpandCard>
{onLoadReports ? (
<InvestigationExpandCard title="报告版本"
preview={<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{snapshot ? `${roundLabel(snapshot)} · 报告第 ${snapshot.revision} 版 · ${dateLabel(snapshot.published_at)}` : "尚未发布报告。"}
</p>}
onOpen={() => { if (history.length === 0 && !historyLoading) void loadHistory(0); }}
>
{(close) => <>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => { selectReport(item); close(); }}
>
<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}
</>}
</InvestigationExpandCard>
) : null}
<p className="px-4 pb-4 pt-1 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</article>
</ExpandableReading>
);
}
function InvestigationTimeline({ items }: { items: InvestigationTimelineItem[] }) {
return <>
<p className="pb-3 text-xs leading-5 text-[var(--ink-muted)]">按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{items.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)]">{formatInvestigationText(item.detail)}</p>
</li>)}
</ol>
{items.length === 0 ? <p className="text-sm text-[var(--ink-muted)]">尚无调查记录。</p> : null}
</>;
}
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}>{formatInvestigationText(item)}</li>
))}
</ul>
</div>
);
}