feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -130,6 +130,35 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender };
}
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
run_id: "70000000-0000-4000-8000-000000000002", source_session_id: "s", execution_generation: taskBase.execution_generation,
status: "pending", version: 1, created_at: taskBase.created_at, answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "sampling", title: "是否有采样记录", body: "请补充采样信息", options: [], mode: "single", allow_custom: true, required: true }] }],
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段", impact: "待核验",
required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }] } }
};
const props = taskPanel(task);
const questions = screen.getByRole("region", { name: "调查提问" });
questions.scrollIntoView = vi.fn();
fireEvent.click(screen.getByRole("button", { name: "回答当前调查问题" }));
expect(questions).toHaveFocus();
expect(questions.scrollIntoView).toHaveBeenCalled();
expect(props.onContinue).not.toHaveBeenCalled();
});
it("labels free discussion separately when no fixed question is pending", () => {
const props = taskPanel({ ...taskBase, latest_report: { ...taskBase.latest_report!, report: {
...taskBase.latest_report!.report, data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段",
impact: "待核验", required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }]
} } });
expect(screen.queryByRole("button", { name: "补充事实" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "讨论此缺口" }));
expect(props.onContinue).toHaveBeenCalled();
});
it("references the selected report round instead of the latest task round, including legacy reports", () => {
const task = { ...taskBase, execution_generation: 2 };
const { rerender, ...props } = taskPanel(task);
@@ -191,7 +220,7 @@ it("opens the execution record bound to the selected report round", async () =>
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true }));
fireEvent.click(screen.getByRole("button", { name: "执行记录" }));
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
executionGeneration: 1,
@@ -75,6 +75,7 @@ type AgentExecutionPlanPanelProps = {
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onRefresh: () => void;
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
@@ -94,11 +95,12 @@ export function AgentExecutionPlanPanel({
onLoadEvidenceSnapshot,
onRefresh,
onLoadReports,
onAnswerQuestion,
onReportViewed,
focusActionId
}: AgentExecutionPlanPanelProps) {
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
@@ -148,7 +149,7 @@ export function AnalysisHistorySection({
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
</span>
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
{item.runTypeLabel} · {formatLocalMonthDayTime(item.createdAt)} · {item.createdBy}
</span>
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
@@ -187,15 +188,7 @@ function statusTone(status: string): StatusTone {
return "neutral";
}
function formatHistoryTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "分析成果历史请求失败";
@@ -1,100 +0,0 @@
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
import {
getFeatureInsightMetrics,
getLocalizedFeatureProperties
} from "../utils/feature-properties";
import { FeaturePropertyList } from "./feature-property-list";
type FeatureInsightPanelProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
const metrics = useMemo(() => {
if (!feature) {
return [];
}
return getFeatureInsightMetrics(feature.layer, feature.properties);
}, [feature]);
const propertyEntries = useMemo(() => {
if (!feature) {
return [];
}
return getLocalizedFeatureProperties(feature.properties);
}, [feature]);
return (
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-[var(--surface-divider)]/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
<h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"}
</h2>
</div>
<button
type="button"
aria-label="关闭详情"
title="关闭详情"
onClick={onClose}
className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-sm text-slate-600">
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
</p>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{feature ? (
<>
<div className="grid grid-cols-2 gap-2">
{metrics.map(({ label, value }) => (
<div key={label} className="surface-reading rounded border p-3">
<p className="text-xs text-[var(--ink-muted)]">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
</div>
))}
</div>
<section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
<TriangleAlert size={16} aria-hidden="true" />
Agent 解释
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
</p>
</section>
<section className="mt-4">
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
<div className="mt-2">
<FeaturePropertyList entries={propertyEntries} />
</div>
</section>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
<Target size={22} aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
</p>
</div>
)}
</div>
</aside>
);
}
@@ -1,24 +0,0 @@
type IconButtonProps = {
label: string;
children: React.ReactNode;
onClick?: () => void;
active?: boolean;
};
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
}`}
>
{children}
</button>
);
}
@@ -0,0 +1,123 @@
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { createInvestigationDevCase } from "../utils/investigation-dev-cases";
import { InvestigationDevTools } from "./investigation-dev-tools";
afterEach(cleanup);
describe("investigation Dev case selection", () => {
it("previews a loaded case in one click without an investigation or separate validation step", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const cases = screen.getByRole("combobox", { name: "JSONL 测试案例" });
expect(cases).toBeEnabled();
await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01"
);
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
"候选管段对比"
);
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("previews data without a map but waits before executing map commands", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady={false}
charts={0}
overlay={null}
activity={[]}
onSample={vi.fn()}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent("地图尚未就绪");
expect(onPreview).toHaveBeenCalledTimes(1);
});
it.each(["invalid_json", "invalid_command", "invalid_chart"] as const)(
"validates %s on execute and never runs a partial batch",
async (id) => {
const user = userEvent.setup();
const onPreview = vi.fn();
render(
<InvestigationDevTools
task={null}
mapReady
charts={0}
overlay={null}
activity={[]}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onPreview={onPreview}
onClear={vi.fn()}
/>
);
const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled();
await user.click(execute);
expect(screen.getByRole("alert")).toHaveTextContent(
id === "invalid_json" ? "第 2 行" : "第 1 行"
);
expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
await user.click(execute);
expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }),
expect.any(String)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
}
);
});
@@ -0,0 +1,410 @@
import { useId, useMemo, useRef, useState } from "react";
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import {
investigationDevCases,
type InvestigationDevCaseId
} from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client";
import {
investigationActionLabels,
investigationReportJsonl,
investigationReportUiSummary,
parseInvestigationDevJsonl,
type InvestigationDevPreview,
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = {
task: OperationalTask | null;
mapReady: boolean;
charts: number;
overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void;
};
export function InvestigationDevTools({
task,
mapReady,
charts,
overlay,
activity,
onSample,
onPreview,
onClear
}: InvestigationDevToolsProps) {
const [open, setOpen] = useState(true);
const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false);
const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
setInput(value);
setCaseId(nextCaseId);
setChecked(null);
setPreview(null);
setMessage(null);
setError(null);
setSelectedRecord(0);
}
function clearLoadedPreview() {
if (!previewActive.current) return;
onClear();
previewActive.current = false;
}
function loadCase(nextCaseId: InvestigationDevCaseId) {
if (running || !mapReady) return;
clearLoadedPreview();
replaceInput(onSample(nextCaseId), nextCaseId);
}
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
function validate() {
try {
const parsed = parseInvestigationDevJsonl(input);
setChecked(parsed);
setError(null);
setMessage(
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
return parsed;
} catch (cause) {
setChecked(null);
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
setMessage(null);
return null;
}
}
async function run() {
if (running || !input.trim()) return;
const parsed = validate();
if (!parsed) return;
if (parsed.commands.length > 0 && !mapReady) {
setError("地图尚未就绪,请稍后再执行本地预览。");
setMessage(null);
return;
}
setRunning(true);
setError(null);
setPreview(parsed);
previewActive.current = true;
try {
await onPreview(parsed, selectedCase?.label ?? "JSONL");
setMessage(
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError"))
setError(cause instanceof Error ? cause.message : "本地展示失败。");
} finally {
setRunning(false);
}
}
function download() {
if (!checked) return;
const url = URL.createObjectURL(
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
anchor.click();
URL.revokeObjectURL(url);
}
return (
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
<Button
type="button"
variant="ghost"
className="h-8 px-0 text-xs"
aria-expanded={open}
aria-controls="investigation-dev-lab"
onClick={() => setOpen(!open)}
>
<FlaskConical size={14} />
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
</Button>
</div>
<p className="break-words text-sm font-semibold text-foreground">
{task ? task.title : "尚未选择调查"}
</p>
<p className="text-muted-foreground">
{task
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
</p>
{task?.latest_report ? (
<p className="text-muted-foreground">
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
{task.latest_report.execution_generation ?? "未知"} 轮
{task.latest_report.execution_generation !== task.execution_generation
? ",上一轮内容"
: ""}
</p>
) : null}
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
{summary.invalid ? (
<p className="text-[var(--status-warning-foreground)]">
{summary.invalid} 个报告界面块校验未通过。
</p>
) : null}
{task && !task.latest_report ? (
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
) : null}
</div>
{open ? (
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
</p>
<FieldSelect
label="JSONL 测试案例"
placeholder="选择案例,立即载入 JSONL"
value={caseId ?? ""}
onValueChange={(value) => {
const nextCase = investigationDevCases.find((item) => item.id === value);
if (nextCase) loadCase(nextCase.id);
}}
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
disabled={!mapReady || running}
contentClassName="workbench-shadcn-surface"
/>
{!mapReady ? (
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
) : null}
{selectedCase ? (
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
预期:{selectedCase.expected}
</p>
) : null}
<p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。
</p>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!mapReady || running}
onClick={() => loadCase(caseId ?? "combined")}
>
载入合成样例
</Button>
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!task || !summary.blocks || running}
onClick={() => {
if (task) {
clearLoadedPreview();
replaceInput(investigationReportJsonl(task));
}
}}
>
载入当前报告
</Button>
</div>
<div className="space-y-1.5 border-t border-border pt-4">
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
<label htmlFor={inputId} className="text-xs font-medium">
调查展示 JSONL
</label>
<Textarea
id={inputId}
value={input}
disabled={running}
onChange={(event) => replaceInput(event.target.value)}
rows={5}
maxLength={200_000}
spellCheck={false}
className="min-h-28 resize-y font-mono text-[11px] leading-5"
placeholder="每行一条展示指令、界面块或带来源的调查数据"
/>
</div>
<Disclosure
label="JSONL 格式说明"
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
>
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
presentation 执行受控展示,ui_block 预览界面;candidate
等调查数据仅展示,不自动操作地图。
</p>
</Disclosure>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
className="text-xs"
disabled={!input.trim() || running}
onClick={validate}
>
<Check size={14} />
校验 JSONL
</Button>
<Button
type="button"
className="text-xs"
disabled={!input.trim() || running}
onClick={() => void run()}
>
<Play size={14} />
{running ? "正在预览" : "执行本地预览"}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
</p>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">03 检查结果</h3>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
disabled={!checked || running}
onClick={download}
>
<Download size={14} />
下载测试 JSONL
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-xs"
onClick={() => {
onClear();
previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。");
setError(null);
}}
>
<RotateCcw size={14} />
清除临时展示
</Button>
</div>
</div>
{message ? (
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
{message}
</p>
) : null}
{error ? (
<p
role="alert"
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
>
{error}
</p>
) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div>
) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { MessageResponse } from "@/shared/ai-elements/message";
import { Button } from "@/shared/ui/button";
@@ -9,6 +9,10 @@ import {
INVESTIGATION_DIAGRAM_FONT,
investigationDiagramConfig,
investigationDiagramPlugins,
investigationDiagramStates,
investigationDiagramNodeId as getNodeId,
investigationNodeState,
paintInvestigationDiagramStates,
styleInvestigationDiagram
} from "../utils/investigation-diagram-style";
@@ -41,33 +45,42 @@ function DiagramUnavailable() {
);
}
const getNodeId = (node: Element | null | undefined) =>
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
export function InvestigationDiagramView({
diagram,
renderDetails,
onAsk,
running = false,
onLocate
}: {
diagram: InvestigationDiagram;
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
running?: boolean;
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
}) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const diagramRoot = useRef<HTMLDivElement>(null);
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))];
if (!displayedStates.length) displayedStates.push("unknown");
const [fontsReady, setFontsReady] = useState(
() => typeof document === "undefined" || !document.fonts
);
useLayoutEffect(() => {
const root = diagramRoot.current;
if (!root) return;
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
paint();
// Mermaid renders asynchronously. Reapply the latest snapshot when it
// replaces the SVG; observing children avoids reacting to our own styles.
const observer = new MutationObserver(paint);
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, [diagram, running]);
useEffect(() => {
if (!document.fonts) return;
let active = true;
void document.fonts
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.load(`500 18px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
.catch(() => undefined)
.then(() => {
if (active) setFontsReady(true);
@@ -82,6 +95,7 @@ export function InvestigationDiagramView({
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
</p>
<div
ref={diagramRoot}
className="investigation-diagram mt-3"
onPointerDownCapture={(event) => {
const node = (event.target as Element).closest?.(".node");
@@ -104,6 +118,7 @@ export function InvestigationDiagramView({
>
{fontsReady ? (
<MessageResponse
key={diagram.mermaid}
icons={diagramControlIcons}
plugins={investigationDiagramPlugins}
mermaid={{
@@ -118,36 +133,25 @@ export function InvestigationDiagramView({
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
)}
</div>
{running && diagram.node_refs.some((node) => node.status === "running") ? (
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p>
) : null}
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
aria-label="图示颜色说明"
>
{diagram.node_refs.some(
(node) => node.evidence_refs.length && !node.action_refs.length
) ? (
<span className="inline-flex items-center gap-1.5">
<div
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
aria-label="图示状态说明"
>
{displayedStates.map((key) => {
const state = investigationDiagramStates[key];
return (
<span key={key} className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
className="h-2.5 w-3.5 rounded-sm border"
style={{ backgroundColor: state.fill, borderColor: state.stroke,
borderStyle: state.dash === "0" ? "solid" : "dashed" }}
/>
引用证据
{state.label}
</span>
) : null}
{diagram.node_refs.some((node) => node.action_refs.length) ? (
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
/>
关联业务动作
</span>
) : null}
</div>
) : null}
);
})}
</div>
{diagram.node_refs.length > 0 ? (
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
{diagram.node_refs.map((node) => (
@@ -161,6 +165,7 @@ export function InvestigationDiagramView({
}
>
{node.label}
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
</button>
))}
@@ -172,16 +177,9 @@ export function InvestigationDiagramView({
aria-label={`节点详情 ${selected.label}`}
>
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : {
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
}[selected.status]}</p> : null}
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
{renderDetails(selected)}
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
{onAsk ? (
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
围绕此节点提问
</Button>
) : null}
</div>
) : null}
</>
@@ -0,0 +1,64 @@
import { useRef, useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InvestigationExpandCard } from "./investigation-expand-card";
describe("investigation card reading", () => {
it("freezes the reading revision until the reader requests the latest content, then restores focus", async () => {
const user = userEvent.setup();
const { rerender } = render(<InvestigationExpandCard title="当前结果" preview={<p>结论一预览</p>} revision="1"><p>结论一正文</p></InvestigationExpandCard>);
const trigger = screen.getByRole("button", { name: "展开当前结果" });
await user.click(trigger);
const dialog = screen.getByRole("dialog", { name: "当前结果" });
expect(document.body.querySelector(".investigation-detail-backdrop")).toBeInTheDocument();
rerender(<InvestigationExpandCard title="当前结果" preview={<p>结论二预览</p>} revision="2"><p>结论二正文</p></InvestigationExpandCard>);
expect(dialog).toHaveTextContent("结论一正文");
expect(dialog).not.toHaveTextContent("结论二正文");
expect(within(dialog).getByRole("status")).toHaveTextContent("当前保留正在阅读的版本");
await user.click(within(dialog).getByRole("button", { name: "查看最新内容" }));
expect(dialog).toHaveTextContent("结论二正文");
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("moves the same live control into the reader and retains a visible inert preview", async () => {
const user = userEvent.setup();
function Controls() {
const [count, setCount] = useState(0);
return <button type="button" onClick={() => setCount((value) => value + 1)}>缩放 {count}</button>;
}
render(<InvestigationExpandCard title="调查脉络" preview={null} reuseContent wide><Controls /></InvestigationExpandCard>);
const control = screen.getByRole("button", { name: "缩放 0" });
await user.click(control);
await user.click(screen.getByRole("button", { name: "展开调查脉络" }));
const dialog = screen.getByRole("dialog", { name: "调查脉络" });
expect(within(dialog).getByRole("button", { name: "缩放 1" })).toBe(control);
const snapshot = document.querySelector("[data-reading-preview]")!;
expect(snapshot).toBeVisible();
expect(snapshot).toHaveAttribute("aria-hidden", "true");
expect(snapshot).toHaveAttribute("inert");
await user.click(control);
await user.click(within(dialog).getByRole("button", { name: "关闭详情" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: "缩放 2" })).toBe(control);
expect(document.querySelector("[data-reading-preview]")).not.toBeInTheDocument();
});
it("allows a follow-up to own focus when it explicitly closes without restoring the trigger", async () => {
const user = userEvent.setup();
function Fixture() {
const target = useRef<HTMLInputElement>(null);
return <><label>继续输入<input ref={target} /></label>
<InvestigationExpandCard title="主要依据" preview={<p>依据预览</p>}>
{(close) => <button type="button" onClick={() => { close(false); requestAnimationFrame(() => target.current?.focus()); }}>继续核查</button>}
</InvestigationExpandCard></>;
}
render(<Fixture />);
await user.click(screen.getByRole("button", { name: "展开主要依据" }));
await user.click(screen.getByRole("button", { name: "继续核查" }));
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
});
});
@@ -1,9 +1,10 @@
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
import { createPortal } from "react-dom";
import * as Dialog from "@radix-ui/react-dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { motion, useReducedMotion } from "motion/react";
import { Maximize2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
import { createReadingPreview } from "@/shared/ui/reading-preview";
import { cn } from "@/shared/ui/cn";
/** Report-specific progressive disclosure. The source stays mounted while reading. */
@@ -27,7 +28,6 @@ export function InvestigationExpandCard({
reuseContent?: boolean;
headingAccessory?: ReactNode;
}) {
const id = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(true);
const [open, setOpen] = useState(false);
@@ -35,6 +35,7 @@ export function InvestigationExpandCard({
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
const previewSlot = useRef<HTMLDivElement | null>(null);
const snapshot = useRef<HTMLElement | null>(null);
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
previewSlot.current = slot;
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
@@ -43,24 +44,22 @@ export function InvestigationExpandCard({
const preview = previewSlot.current;
if (!preview || !contentHost) return;
if (slot) {
// Reserve only the vacated inline space while expanded. The graph itself
// keeps its natural height, including after content updates or resizing.
// Use the layout height, not a bounding box scaled by the card animation.
preview.style.height = getComputedStyle(preview).height;
snapshot.current?.remove();
snapshot.current = createReadingPreview(contentHost);
preview.append(snapshot.current);
slot.appendChild(contentHost);
} else {
snapshot.current?.remove();
snapshot.current = null;
preview.appendChild(contentHost);
preview.style.removeProperty("height");
}
}, [contentHost]);
const reduced = useReducedMotion();
const duration = reduced || keyboard ? 0 : 0.24;
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
const content = reading?.content ?? children;
return (
<Dialog.Root open={open} onOpenChange={(next) => {
<Dialog open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
restoreFocusRef.current = true;
@@ -68,69 +67,52 @@ export function InvestigationExpandCard({
onOpen?.();
}
}}>
<motion.section
<section
ref={contentRef}
aria-label={label}
className={cn("investigation-report-section investigation-expand-card", className)}
layoutId={reduced || keyboard ? undefined : id}
layoutDependency={open}
transition={transition}
style={{ borderRadius: 12 }}
>
<div className="investigation-card-heading">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
{headingAccessory}
</div>
<Dialog.Trigger asChild>
<DialogTrigger asChild>
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
<Maximize2 size={15} aria-hidden />
</Button>
</Dialog.Trigger>
</DialogTrigger>
</div>
{/* Contain child margins so the occupied and vacated slots measure alike. */}
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
</motion.section>
</section>
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
<Dialog.Portal forceMount>
<AnimatePresence initial={false}>
{open ? (
<Dialog.Overlay forceMount asChild key="backdrop">
<motion.div className="investigation-detail-backdrop"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
</Dialog.Overlay>
) : null}
{open ? (
<div className="investigation-detail-positioner" key="detail">
<Dialog.Content forceMount asChild aria-describedby={undefined}
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={transition} style={{ borderRadius: 12 }}>
<header className="investigation-detail-heading">
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
<Dialog.Close asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
</Dialog.Close>
</header>
<div className="investigation-detail-scroll">
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
</div>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</motion.section>
</Dialog.Content>
</div>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
<DialogContent
className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
<header className="investigation-detail-heading border-b border-border">
<DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
<DialogClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
</DialogClose>
</header>
<div className="investigation-detail-scroll">
<motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
</motion.div>
</div>
{reading && revision !== undefined && reading.revision !== revision ? (
<div className="investigation-report-update" role="status">
<span>已有更新,当前保留正在阅读的版本。</span>
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
import { forwardRef, useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useIsPresent } from "motion/react";
import { ChevronDown, FileText } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
// Forward the element ref so AnimatePresence can remove a card without jumping its siblings.
export const PresentationItem = forwardRef<
HTMLDivElement,
{ immediate: boolean; children: ReactNode }
>(function PresentationItem({ immediate, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
layout={immediate ? false : "position"}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
>
{children}
</motion.div>
);
});
export const PresentationViewport = forwardRef<
HTMLDivElement,
{ immediate: boolean; id: string; children: ReactNode }
>(function PresentationViewport({ immediate, id, children }, ref) {
const present = useIsPresent();
return (
<motion.div
ref={ref}
{...presentationFade(immediate)}
inert={!present || undefined}
aria-hidden={!present || undefined}
id={present ? id : undefined}
data-investigation-content={present ? "" : undefined}
className="relative min-h-0 space-y-3 overflow-y-auto overscroll-contain p-3 [scrollbar-gutter:stable]"
>
{children}
</motion.div>
);
});
export function PresentationReferences({
entries,
immediate
}: {
entries: Array<{ id: string; text: string }>;
immediate: boolean;
}) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div className="mt-3 border-t border-border pt-1">
<Button
type="button"
variant="ghost"
className="h-10 w-full justify-between gap-2 px-0 text-xs font-normal text-muted-foreground"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
<span className="inline-flex items-center gap-1.5">
<FileText size={13} aria-hidden />
查看依据<span className="tabular-nums">{entries.length}</span>
</span>
<motion.span
className="inline-flex"
animate={{ rotate: open ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={13} aria-hidden />
</motion.span>
</Button>
<AnimatePresence initial={false}>
{open ? (
<motion.ul
key="references"
id={id}
{...presentationFade(immediate)}
className="space-y-2 pb-2 pt-1 text-xs leading-5 text-muted-foreground"
>
{entries.map((entry) => (
<li key={entry.id} className="break-words [overflow-wrap:anywhere]">
{entry.text}
</li>
))}
</motion.ul>
) : null}
</AnimatePresence>
</div>
);
}
@@ -1,55 +1,354 @@
import { useState } from "react";
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import {
PresentationItem,
PresentationViewport,
PresentationReferences
} from "./investigation-presentation-motion";
import {
presentationFade,
presentationTransition
} from "../utils/investigation-presentation-motion";
import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null;
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null;
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void; onCloseChart: (id: string) => void;
onToggleOverlay: () => void; onFitOverlay: () => void;
blocks?: AnswerUiBlock[];
records?: Array<Record<string, unknown>>;
evidence?: Array<{ evidence_id: string; summary: string }>;
history?: PresentationHistoryControls;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
scope?: string;
embedded?: boolean;
task: OperationalTask | null;
localPreview?: boolean;
charts: InvestigationChart[];
overlay: InvestigationOverlay | null;
cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error";
error: string | null;
width: number;
relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
onResumeCamera: () => void;
onCloseChart: (id: string) => void;
onToggleOverlay: () => void;
onFitOverlay: () => void;
onToggleLayer: (id: string, visible: boolean) => void;
};
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers,
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) {
const statusLabels = {
connecting: "连接中",
live: "实时跟随",
history: "已载入历史",
disconnected: "连接中断",
error: "部分未完成"
};
export function InvestigationPresentationPanel({
scope = "investigation",
embedded = false,
task,
blocks = [],
records = [],
evidence,
history,
onLocateTargets,
localPreview = false,
charts,
overlay,
cameraPaused,
status,
error,
width,
relatedLayers,
onResumeCamera,
onCloseChart,
onToggleOverlay,
onFitOverlay,
onToggleLayer
}: Props) {
const [collapsed, setCollapsed] = useState(false);
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2">
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)}
</Disclosure>;
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel"
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs"
style={{ width, maxWidth: "calc(100% - 24px)" }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">调查实时展示</p>
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button>
</div>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p>
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null}
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null}
{!collapsed ? <>
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1">
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)}
</div> : null}
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<p className="text-sm font-medium">{overlay.title}</p>
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button>
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion();
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
const reviewing = history?.selectedId != null;
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
const references = (ids: string[]) =>
ids.length ? (
<PresentationReferences
immediate={immediate}
entries={ids.map((id) => ({
id,
text:
(evidence ?? task?.evidence)?.find((item) => item.evidence_id === id)?.summary ??
(localPreview && id === "dev:synthetic"
? "合成测试数据,未保存为调查证据。"
: `关联资料:${id}`)
}))}
/>
) : null;
return (
<motion.aside
layout={immediate ? false : "size"}
{...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)}
aria-label={title}
data-testid="investigation-presentation-panel"
data-presentation-scope={scope}
data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
>
<motion.header
layout={immediate ? false : "position"}
transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing
? "历史回看"
: scope === "chat"
? "对话联动"
: localPreview
? "本地测试"
: statusLabels[status]}
</Badge>
<span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span>
{reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground">
后续 {pending} 个动作
</span>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)}
>
<motion.span
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
</div>
{references([overlay.evidence_id])}
</section> : null}
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3">
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div>
<AgentChartView chart={item.chart} />
{references(item.evidence_refs)}
</section>)}
</> : null}
</aside>;
<p
className={
history?.entries.length
? "sr-only"
: "mt-1.5 text-[11px] leading-4 text-muted-foreground"
}
>
{selected
? `${selected.taskTitle ? `${selected.taskTitle} · ` : ""}${new Date(selected.createdAt).toLocaleTimeString("zh-CN", { hour12: false })} · ${selected.label}${pending ? ` · 后续 ${pending} 个动作` : ""}`
: scope === "chat"
? "展示当前对话的地图操作与结果"
: localPreview
? "仅当前浏览器,不写入调查任务"
: status === "history"
? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"}
</p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
</motion.header>
<AnimatePresence initial={false} mode="popLayout">
{!collapsed ? (
<PresentationViewport
key={history?.selectedId ?? "current"}
immediate={immediate}
id={contentId}
>
{selected?.message ? (
<p role="status" className="text-xs leading-5 text-muted-foreground">
{selected.message}
</p>
) : null}
{!reviewing && !localPreview && error ? (
<p
role="status"
className="rounded-md border border-border px-2.5 py-2 text-xs leading-5 text-muted-foreground"
>
{error}
</p>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{cameraPaused ? (
<PresentationItem key="camera" immediate={immediate}>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-xs">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Pause size={13} aria-hidden />
镜头跟随已暂停
</span>
<Button type="button" size="sm" variant="outline" onClick={onResumeCamera}>
{localPreview || scope === "chat" ? "恢复自动定位" : "恢复跟随"}
</Button>
</div>
</PresentationItem>
) : null}
</AnimatePresence>
{relatedLayers.length ? (
<section aria-label="调查相关图层" className="space-y-2">
<h3 className="text-[11px] font-medium text-muted-foreground">相关图层</h3>
<div className="flex flex-wrap gap-2">
{relatedLayers.map((layer) => (
<Button
key={layer.id}
type="button"
variant="outline"
size="sm"
className="h-auto min-h-8 whitespace-normal text-left"
disabled={reviewing}
aria-pressed={layer.visible}
onClick={() => onToggleLayer(layer.id, !layer.visible)}
>
{layer.visible ? (
<Eye size={14} aria-hidden />
) : (
<EyeOff size={14} aria-hidden />
)}
{layer.label}
</Button>
))}
</div>
</section>
) : null}
<AnimatePresence initial={false} mode="popLayout">
{overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
<CardHeader className="space-y-1.5 p-3 pb-2">
<div className="flex items-start gap-2">
<MapPinned
size={15}
className="mt-0.5 shrink-0 text-muted-foreground"
aria-hidden
/>
<h3 className="min-w-0 break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{overlay.title}
</h3>
</div>
<p className="text-[11px] leading-5 text-muted-foreground">
{localPreview
? "本地空间预览,范围以输入 JSONL 为准"
: overlay.mode === "heatmap"
? "记录集中度,颜色不表示已确认影响范围"
: "调查范围与关联空间资料"}
</p>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={reviewing}
onClick={onToggleOverlay}
>
{overlay.visible ? (
<EyeOff size={14} aria-hidden />
) : (
<Eye size={14} aria-hidden />
)}
{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}
</Button>
<Button type="button" size="sm" variant="outline" onClick={onFitOverlay}>
<LocateFixed size={14} aria-hidden />
定位覆盖层
</Button>
</div>
{references([overlay.evidence_id])}
</CardContent>
</Card>
</PresentationItem>
) : null}
{charts.map((item) => (
<PresentationItem key={item.chart_id} immediate={immediate}>
<Card
data-investigation-chart={item.chart_id}
role="group"
aria-label={item.chart.title}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem>
))}
</AnimatePresence>
<PresentationBusinessResults
blocks={blocks}
records={records}
reviewing={reviewing}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
!overlay &&
!blocks.length &&
!records.length &&
!relatedLayers.length ? (
<p className="text-xs leading-5 text-muted-foreground">
当前没有展示结果,可切换历史动作回看地图与结果。
</p>
) : null}
</PresentationViewport>
) : null}
</AnimatePresence>
</motion.aside>
);
}
@@ -0,0 +1,50 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { OperationalTask, TaskQuestionRequest } from "../api/operational-task-client";
import { InvestigationQuestions } from "./investigation-questions";
const question: TaskQuestionRequest = {
question_request_id: "70000000-0000-4000-8000-000000000001", task_id: "70000000-0000-4000-8000-000000000002",
run_id: "70000000-0000-4000-8000-000000000003", source_session_id: "s", execution_generation: 1,
status: "pending", version: 1, created_at: "2026-10-30T01:00:00Z", answered_at: null, answered_by: null, answers: [],
questions: [{ question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }]
};
const task = { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
permissions: { can_answer_questions: true } } as OperationalTask;
describe("investigation questions", () => {
it("requires an answer, retains input after failure and retries the same request id", async () => {
const onAnswer = vi.fn().mockRejectedValueOnce(new Error("网络失败")).mockResolvedValueOnce({});
render(<InvestigationQuestions task={task} onAnswer={onAnswer} />);
const submit = screen.getByRole("button", { name: "提交回答并继续调查" });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "目前未知" }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "请保留为未知" } });
fireEvent.click(submit);
expect(await screen.findByRole("alert")).toHaveTextContent("网络失败");
expect(screen.getByRole("textbox")).toHaveValue("请保留为未知");
fireEvent.click(submit);
await waitFor(() => expect(onAnswer).toHaveBeenCalledTimes(2));
expect(onAnswer.mock.calls[0]).toEqual(onAnswer.mock.calls[1]);
expect(onAnswer.mock.calls[0].slice(0, 3)).toEqual([question.question_request_id, 1,
[{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "请保留为未知" }]]);
expect(await screen.findByText(/回答已保存,调查已排队继续/)).toBeVisible();
});
it.each([
{ ...task, permissions: { ...task.permissions, can_answer_questions: false } },
{ ...task, active_run: { status: "running" } },
{ ...task, execution_generation: 2 }
])("does not answer without permission or for active/stale runs", (value) => {
render(<InvestigationQuestions task={value as OperationalTask} onAnswer={vi.fn()} />);
expect(screen.getByRole("radio")).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
});
it("shows stored option labels and text after reload without another answer action", () => {
render(<InvestigationQuestions task={{ ...task, questions: [{ ...question, status: "answered", version: 2,
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "没有记录" }] }] }} />);
expect(screen.getByText("目前未知;没有记录")).toBeVisible();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,82 @@
import { useRef, useState, type Ref } from "react";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
}) {
const questions = task.questions ?? [];
if (!questions.length) return null;
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
request={request} onAnswer={onAnswer}
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
</section>;
}
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
}) {
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
question_id: q.question_id, selected_option_ids: [], custom_text: ""
})));
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const attempt = useRef<{ body: string; id: string } | null>(null);
const pending = request.status === "pending" && !submitted;
const disabled = !canAnswer || !onAnswer || busy || !pending;
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|| (q.allow_custom && answers[index].custom_text.trim()));
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
const submit = async () => {
if (disabled || !valid) return;
const body = JSON.stringify(answers);
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
setBusy(true); setError(null);
try {
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
setSubmitted(true);
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
finally { setBusy(false); }
};
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
{request.questions.map((question, index) => {
const answer = answers[index];
const saved = request.answers.find((item) => item.question_id === question.question_id);
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
{pending ? <>
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
})} />}
<span className="min-w-0 break-words">{option.label}</span>
</label>)}
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
</fieldset>;
})}
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
{pending ? <div className="space-y-2">
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
</div> : null}
</article>;
}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { InvestigationReportBlocks } from "./investigation-report-blocks";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
@@ -22,20 +22,16 @@ const snapshot: OperationalTaskReport = {
};
describe("formal report interactive content", () => {
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
it("shows saved evidence facts without quoting or dispatching work", async () => {
const onContinue = vi.fn();
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
await waitFor(() => expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible());
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
context: { report_revision: 3 }, reportGeneration: 2,
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
}));
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
expect(screen.queryByText("引用依据:保存的监测来源")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(onContinue).not.toHaveBeenCalled();
});
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
const next = structuredClone(snapshot);
@@ -44,7 +40,28 @@ describe("formal report interactive content", () => {
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("分析待核验。")).toBeVisible();
expect(container.querySelector("iframe")).toBeNull();
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
});
it("keeps references not already represented by an evidence card", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].evidence_refs.push("other-evidence");
const expandedTask = { evidence: [...task.evidence, { ...task.evidence[0], evidence_id: "other-evidence", source_label: "另一条监测来源" }] };
render(<InvestigationReportBlocks task={expandedTask} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByText("保存的监测来源")).toBeVisible();
expect(screen.getByText("引用依据:另一条监测来源")).toBeVisible();
expect(screen.queryByText("引用依据:保存的监测来源、另一条监测来源")).not.toBeInTheDocument();
});
it("keeps evidence references for tables without evidence cards", () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "table", elements: { table: {
type: "Table", children: [], props: { title: "监测记录", columns: ["对象"], rows: [["北区"]] },
} } };
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
expect(screen.getByRole("table")).toHaveTextContent("北区");
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
});
it("turns report form input into a versioned conversation draft", async () => {
const next = structuredClone(snapshot);
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
@@ -58,5 +75,9 @@ describe("formal report interactive content", () => {
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
expect(onContinue.mock.calls[0][0].reportGeneration).toBe(2);
expect(onContinue.mock.calls[0][0].reference.title).toBe("演练调查报告 · 报告第 3 版 · 补充资料");
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
});
});
@@ -24,7 +24,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
<div>
<h3 className="text-sm font-semibold">数据与分析</h3>
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
<p className="mt-1 text-xs text-muted-foreground">查看报告中的数据与依据;定位和补充资料使用各自入口。</p>
</div>
{blocks.map((item) => {
const parsed = parseAnswerUiBlock(item.block);
@@ -49,14 +49,17 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
}))
} } : null;
const shownEvidence = new Set(block ? Object.values(block.spec.elements)
.filter((node) => node.type === "Evidence").map((node) => node.props.evidence_id) : []);
const remainingRefs = item.evidence_refs.filter((id) => !shownEvidence.has(id));
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
{block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
<p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
</p>
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> : null}
</div>;
})}
</section>;
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
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";
@@ -34,6 +35,7 @@ type Props = {
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;
@@ -82,6 +84,12 @@ function investigationTimeline(
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({
@@ -133,10 +141,14 @@ export function InvestigationTaskReport({
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[]>([]);
@@ -313,6 +325,8 @@ export function InvestigationTaskReport({
</div>
</header>
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
{latestAvailable ? (
@@ -489,14 +503,19 @@ export function InvestigationTaskReport({
<Button
variant="outline"
size="sm"
onClick={() =>
discuss(
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
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>
@@ -548,12 +567,6 @@ export function InvestigationTaskReport({
) : null}
</>
)}
onAsk={(node) =>
discuss(
`请解释调查脉络中的“${node.label}”及其关联依据。`,
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
)
}
/>
) : null}
@@ -50,6 +50,24 @@ describe("MapDevPanel", () => {
expect(onFollowChange).toHaveBeenCalledWith(false);
});
it("keeps map inputs when switching between investigation and map tools", async () => {
const user = userEvent.setup();
const commands = createCommands();
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
detailFeature={null} onClose={vi.fn()} onInvestigationFollowChange={vi.fn()} />);
expect(screen.getByRole("tab", { name: "调查测试" })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("button", { name: "定位并高亮" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
const featureId = screen.getByLabelText("要素 ID(可多个)");
await user.clear(featureId);
await user.type(featureId, "DEMO-PIPE-03");
await user.click(screen.getByRole("tab", { name: "调查测试" }));
expect(screen.queryByRole("button", { name: "重置地图调试效果" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "地图调试" }));
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("DEMO-PIPE-03");
expect(commands.resetAll).not.toHaveBeenCalled();
});
it("updates a pipe with its signed velocity", async () => {
const user = userEvent.setup();
const commands = createCommands();
@@ -6,25 +6,27 @@ import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
type LayerGroupStylePatch
} from "../map/layer-group-style";
import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { getNumericFeatureProperties } from "../map/value-label";
import type {
FeatureBatchTarget,
WorkbenchMapCommands,
WorkbenchMapControllerState
} from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId
} from "../map/sources";
import type { DetailFeature } from "../types";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
type MapDevPanelProps = {
commands: WorkbenchMapCommands;
@@ -34,6 +36,7 @@ type MapDevPanelProps = {
onClose: () => void;
investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps;
};
const GROUP_LABELS = Object.fromEntries(
@@ -109,8 +112,10 @@ export function MapDevPanel({
flowDataSource = "mock",
onClose,
investigationFollow = true,
onInvestigationFollowChange
onInvestigationFollowChange,
investigationTools
}: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
const [featureId, setFeatureId] = useState("440681");
const [property, setProperty] = useState("diameter");
@@ -241,17 +246,17 @@ export function MapDevPanel({
return (
<aside
aria-label="地图开发面板"
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-foreground">
地图调试{" "}
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
开发测试{" "}
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2>
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
按功能展开 · 设置仅本次页面会话生效
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
调查联动验证与地图工具 · 仅当前页面生效
</p>
</div>
<Button
@@ -265,429 +270,436 @@ export function MapDevPanel({
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div
role="alert"
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
>
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
{onInvestigationFollowChange ? (
<PanelSection
title="调查联动"
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
initiallyOpen
status={investigationFollow ? "已开启" : "已关闭"}
>
<ToggleField
label="跟随所选调查"
checked={investigationFollow}
onChange={onInvestigationFollowChange}
/>
</PanelSection>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
variant="outline"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
<FieldSelect
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
<TabsTrigger value="map">地图调试</TabsTrigger>
</TabsList>
</div>
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4">
<div className="space-y-2">
<ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
<p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
{investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
</section>
) : null}
</TabsContent>
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{controllerState.errorCode ? (
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
</div>
) : null}
<PanelSection
title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
initiallyOpen
status={
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
>
<div className="space-y-3">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务源"
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection>
<PanelSection
title="水流调试"
status={controllerState.flowVisible ? "已开启" : "未开启"}
description={
flowDataSource === "realtime"
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
}
>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={controllerState.pending || controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(true)}
className="w-full text-xs"
>
开启水流
</Button>
<Button
type="button"
disabled={!controllerState.flowVisible}
onClick={() => void commands.setFlowVisible(false)}
className="w-full text-xs"
variant="outline"
>
关闭水流
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="管道 ID">
<Input
value={flowPipeId}
onChange={(event) => setFlowPipeId(event.target.value)}
className={inputClassName}
/>
</Field>
<Field label="有符号流速">
<Input
type="number"
step="any"
value={flowVelocity}
onChange={(event) => setFlowVelocity(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
<Button
type="button"
disabled={!validFlowCalculation || controllerState.pending}
onClick={applyFlowCalculation}
className="w-full text-xs"
>
更新该管道方向
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={sourceId !== "pipes" || featureIds.length !== 1}
onClick={() => setFlowPipeId(featureIds[0])}
className="w-full text-xs"
variant="outline"
>
使用 Feature ID
</Button>
<Button
type="button"
onClick={() => void commands.clearPipeFlowCalculations()}
className="w-full text-xs"
variant="outline"
>
清空流速
</Button>
</div>
{flowSummary.total > 0 ? (
<StatusText>
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
{flowSummary.stopped}
</StatusText>
) : controllerState.flowVisible ? (
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
) : (
<StatusText>开启后显示演示流向</StatusText>
)}
</PanelSection>
<PanelSection
title="图层样式"
status={
controllerState.styledGroups.length > 0
? `${controllerState.styledGroups.length} 组已覆盖`
: undefined
}
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="业务图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
{geometry === "line" ? (
<>
<ColorField
label="颜色"
value={String(draft.color)}
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
/>
<RangeField
label="图标倍率"
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="宽度倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.iconMultiplier)}
value={Number(draft.widthMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
setDraft((current) => ({ ...current, widthMultiplier: value }))
}
/>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="线型"
value={String(draft.dash)}
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
options={[
{ value: "original", label: "原始" },
{ value: "solid", label: "实线" },
{ value: "dashed", label: "虚线" }
]}
/>
</>
) : (
<>
<div className="grid grid-cols-2 gap-2">
<ColorField
label="填充"
value={String(draft.fillColor)}
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
/>
<ColorField
label="描边"
value={String(draft.strokeColor)}
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
/>
</div>
<RangeField
label="透明度"
min={0}
max={1}
step={0.05}
value={Number(draft.opacity)}
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
/>
<RangeField
label="半径倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.radiusMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, radiusMultiplier: value }))
}
/>
<RangeField
label="描边倍率"
min={0.5}
max={3}
step={0.1}
value={Number(draft.strokeMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, strokeMultiplier: value }))
}
/>
{groupId !== "junctions" ? (
<RangeField
label="图标倍率"
min={0.5}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
step={0.1}
value={Number(draft.iconMultiplier)}
onChange={(value) =>
setDraft((current) => ({ ...current, iconMultiplier: value }))
}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</div>
) : null}
</>
)}
<div className="grid grid-cols-2 gap-2">
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
应用图层样式
</Button>
<Button
type="button"
onClick={() => commands.resetLayerGroupStyle(groupId)}
className="w-full text-xs"
variant="outline"
>
重置本组
</Button>
</div>
{controllerState.styledGroups.includes(groupId) ? (
<StatusText>已覆盖当前图层组样式</StatusText>
) : null}
</PanelSection>
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
<PanelSection
title="数值标注"
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
status={
hasActiveLabels
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
: undefined
}
>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="标注图层组"
value={groupId}
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
/>
<div className="grid grid-cols-2 gap-2">
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
</div>
{showValueLabels ? (
<>
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="数值属性"
value={property}
onValueChange={setProperty}
options={LABEL_PRESETS[groupId].options}
/>
<div className="grid grid-cols-2 gap-2">
<Field label="精度 0-3">
<Input
type="number"
min={0}
max={3}
value={precision}
onChange={(event) => setPrecision(Number(event.target.value))}
className={inputClassName}
/>
</Field>
<Field label="单位">
<Input
maxLength={16}
value={unit}
onChange={(event) => setUnit(event.target.value)}
className={inputClassName}
/>
</Field>
</div>
</>
) : null}
<RangeField
label="字号"
min={10}
max={18}
step={1}
value={labelSize}
onChange={setLabelSize}
/>
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!showValueLabels && !showFeatureIds}
onClick={applyLabels}
className="w-full text-xs"
>
应用标注
</Button>
<Button
type="button"
disabled={!hasActiveLabels}
onClick={() => {
setShowValueLabels(false);
setShowFeatureIds(false);
commands.clearValueLabel();
}}
className="w-full text-xs"
variant="outline"
>
清除全部标注
</Button>
</div>
{activeLabel ? (
<StatusText>
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
</StatusText>
) : null}
</PanelSection>
</TabsContent>
</Tabs>
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
<Button
type="button"
onClick={() => commands.resetAll()}
@@ -718,12 +730,12 @@ function PanelSection({
return (
<Disclosure
initiallyOpen={initiallyOpen}
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
label={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="font-semibold">{title}</span>
{status ? (
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
{status}
</span>
) : null}
@@ -731,7 +743,7 @@ function PanelSection({
}
>
<div className="space-y-3 px-2 pb-3 pt-1">
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
<p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
{children}
</div>
</Disclosure>
@@ -741,7 +753,7 @@ function PanelSection({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block min-w-0">
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
@@ -757,7 +769,7 @@ function ToggleField({
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
<Checkbox
checked={checked}
onChange={(event) => onChange(event.target.checked)}
@@ -778,7 +790,7 @@ function ColorField({
}) {
return (
<Field label={label}>
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
<input
type="color"
value={value}
@@ -786,7 +798,7 @@ function ColorField({
aria-label={label}
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
/>
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
<span className="text-xs font-medium tabular-nums text-muted-foreground">{value}</span>
</div>
</Field>
);
@@ -835,4 +847,4 @@ function StatusText({ children, tone = "success" }: { children: React.ReactNode;
}
const inputClassName =
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";
"h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";
@@ -0,0 +1,77 @@
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = {
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
reviewing: boolean;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
};
export function PresentationBusinessResults({
blocks,
records,
reviewing,
onLocateTargets
}: Props) {
if (!blocks.length && !records.length) return null;
return (
<section aria-label="展示结果" className="min-w-0 space-y-3">
{blocks.map((block) => (
<AnswerUiView
key={block.block_id}
block={block}
disabled={reviewing}
onLocate={
onLocateTargets && !reviewing
? (target) =>
onLocateTargets(
target.feature_ids.map((feature_id) => ({
source_id: target.layer,
feature_id
}))
)
: undefined
}
/>
))}
{!blocks.length && records.length ? (
<Card className="border-border shadow-none">
<CardHeader className="p-3 pb-2">
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
</CardHeader>
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 font-medium">来源</th>
</tr>
</thead>
<tbody>
{records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="break-all py-1.5 pr-3">
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</td>
<td className="break-all py-1.5">{String(record.origin)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
) : null}
</section>
);
}
@@ -0,0 +1,60 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
afterEach(cleanup);
function entries() {
const history = new PresentationViewHistory();
history.record("压力曲线", "preview", null);
history.update({ ...history.current, records: [{ element_id: "V1" }] });
history.record("阀门列表", "preview", null);
return history.entries;
}
it("merges the latest result into current and skips its duplicate when going back", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
render(
<PresentationHistorySwitcher
entries={history}
currentEntryId={history[1].id}
selectedId={null}
onSelect={onSelect}
/>
);
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
expect(screen.getAllByRole("option")).toHaveLength(2);
expect(screen.getByRole("option", { name: /当前(最新).*阀门列表/ })).toBeVisible();
expect(screen.getByRole("option", { name: /1\. 压力曲线/ })).toBeVisible();
expect(screen.queryByRole("option", { name: /^2\. 阀门列表/ })).not.toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "上一个动作" }));
expect(onSelect).toHaveBeenCalledWith(history[0].id);
});
it("keeps the final historical result selectable when current has been cleared", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
render(
<PresentationHistorySwitcher
entries={history}
currentEntryId={null}
selectedId={null}
onSelect={onSelect}
/>
);
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
"当前(最新) · 2 个动作",
"2. 阀门列表 · 完成",
"1. 压力曲线 · 完成"
]);
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
expect(onSelect).toHaveBeenCalledWith(history[1].id);
});
@@ -0,0 +1,97 @@
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
onSelect
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter(
(entry) => entry.status !== "running" && entry.id !== currentEntryId
);
const index =
selectedId === null
? available.length
: available.findIndex((entry) => entry.id === selectedId);
return (
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="上一个动作"
disabled={index <= 0}
onClick={() => onSelect(available[index - 1].id)}
>
<ChevronLeft size={14} aria-hidden />
</Button>
<Select
value={selectedId === null ? "current" : String(selectedId)}
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
>
<SelectTrigger
aria-label="切换展示动作"
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
>
<SelectValue />
</SelectTrigger>
<SelectContent
position="popper"
sideOffset={4}
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
>
<SelectItem value="current" className="text-xs">
当前(最新)
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
{entries.length} 个动作
</SelectItem>
{entries
.slice()
.reverse()
.map((entry, position) =>
entry.id === currentEntryId ? null : (
<SelectItem
key={entry.id}
value={String(entry.id)}
disabled={entry.status === "running"}
className="whitespace-normal text-xs [&>span:last-child]:break-words"
>
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
aria-label="下一个动作"
disabled={selectedId === null}
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
>
<ChevronRight size={14} aria-hidden />
</Button>
{selectedId !== null ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
onClick={() => onSelect(null)}
>
<Radio size={12} aria-hidden />
回到当前
</Button>
) : null}
</div>
);
}
@@ -26,7 +26,6 @@ import type {
} from "../api/operational-task-client";
import { cn } from "@/shared/ui/cn";
import { formatDateTime } from "@/shared/utils/date-time";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
StatusIcon as SemanticStatusIcon,
@@ -115,6 +114,7 @@ type ConditionDetailPanelProps = {
workOrderDataSource?: WorkbenchWorkOrderDataSource;
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -171,6 +171,7 @@ export function ConditionDetailPanel({
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -200,6 +201,7 @@ export function ConditionDetailPanel({
onContinueConversation={onContinueConversation}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onAnswerTaskQuestion={onAnswerTaskQuestion}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
@@ -237,6 +239,7 @@ function ConditionRecordDetailPanel({
onContinueConversation,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -255,6 +258,7 @@ function ConditionRecordDetailPanel({
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
onViewTaskExecutionHistory?: ConditionDetailPanelProps["onViewTaskExecutionHistory"];
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
onAnswerTaskQuestion?: ConditionDetailPanelProps["onAnswerTaskQuestion"];
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
@@ -322,6 +326,7 @@ function ConditionRecordDetailPanel({
<AgentExecutionPlanPanel
onLocate={onLocate}
task={condition.operationalTask}
onAnswerQuestion={onAnswerTaskQuestion ? (...args) => onAnswerTaskQuestion(condition.operationalTask!.task_id, ...args) : undefined}
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
focusActionId={focusActionId}
@@ -8,7 +8,6 @@ import {
X,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
@@ -87,6 +86,7 @@ type ScheduledConditionFeedProps = {
onSubmitPrompt: (prompt: string) => void | Promise<void>;
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void>;
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
@@ -129,6 +129,7 @@ export function ScheduledConditionFeed({
onOpenTaskCollaboration,
onViewTaskExecutionHistory,
onTaskDecision,
onAnswerTaskQuestion,
onTaskExecute,
onTaskDraftUpdate,
onTaskConfirmDispatch,
@@ -211,7 +212,6 @@ export function ScheduledConditionFeed({
: [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions, taskCategory]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const headerSummary = loading && backendLoading
@@ -559,6 +559,7 @@ export function ScheduledConditionFeed({
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
onTaskDecision={onTaskDecision}
onAnswerTaskQuestion={onAnswerTaskQuestion}
onTaskExecute={onTaskExecute}
onTaskDraftUpdate={onTaskDraftUpdate}
onTaskConfirmDispatch={onTaskConfirmDispatch}
@@ -1,43 +0,0 @@
import { ShieldCheck } from "lucide-react";
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
import { investigationStatus } from "../utils/investigation-presentation";
type TaskConversationReviewProps = {
task: OperationalTask;
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
onRefresh: () => void;
};
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending");
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
{recentFacts.length > 0 ? <details>
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
</li>)}</ul>
</details> : null}
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
canDecide={task.permissions.can_decide_access}
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
onRefresh={onRefresh} />)}
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
</section>;
}
@@ -52,26 +52,27 @@ export function WorkbenchSection({
}) {
return (
<section
data-slot="workbench-section"
className={cn(
"overflow-hidden rounded-[14px] border border-[var(--surface-divider)]/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
className
)}
{...props}
>
<div className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
<div data-slot="workbench-section-header" className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
{icon ? (
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
{icon}
</span>
) : null}
<div className="min-w-0 flex-1">
<div data-slot="workbench-section-heading" className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
{meta}
</div>
{description ? <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
{actions ? <div data-slot="workbench-section-actions" className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
<div className={cn("p-3.5", bodyClassName)}>{children}</div>
</section>
@@ -93,6 +94,8 @@ export function WorkbenchNotice({
const Icon = noticeIcon[tone];
return (
<div
data-slot="workbench-notice"
data-tone={tone}
className={cn(
"flex min-w-0 gap-2.5 rounded-r-lg border-l-2 px-3 py-2.5 text-xs leading-5",
noticeTone[tone],