Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback. The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes. Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide.
343 lines
15 KiB
TypeScript
343 lines
15 KiB
TypeScript
import { useId, useState } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
|
|
import type { AnswerUiBlock } from "@/features/agent";
|
|
import { Badge } from "@/shared/ui/badge";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
|
import { LoadingSkeleton } from "@/shared/ui/deferred-render";
|
|
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 { InvestigationChartManager } from "./investigation-chart-manager";
|
|
import { InvestigationChartGroup } from "./investigation-chart-group";
|
|
import { groupInvestigationCharts } from "../utils/investigation-chart-groups";
|
|
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 = {
|
|
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[];
|
|
chartWindowAutoOpen?: boolean;
|
|
chartWindowScope?: string;
|
|
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;
|
|
};
|
|
|
|
const statusLabels = {
|
|
connecting: "连接中",
|
|
live: "实时跟随",
|
|
history: "已载入历史",
|
|
disconnected: "连接中断",
|
|
error: "部分未完成"
|
|
};
|
|
|
|
export function InvestigationPresentationPanel({
|
|
scope = "investigation",
|
|
embedded = false,
|
|
task,
|
|
blocks = [],
|
|
records = [],
|
|
evidence,
|
|
history,
|
|
onLocateTargets,
|
|
localPreview = false,
|
|
charts,
|
|
chartWindowAutoOpen = false,
|
|
chartWindowScope,
|
|
overlay,
|
|
cameraPaused,
|
|
status,
|
|
error,
|
|
width,
|
|
relatedLayers,
|
|
onResumeCamera,
|
|
onCloseChart,
|
|
onToggleOverlay,
|
|
onFitOverlay,
|
|
onToggleLayer
|
|
}: Props) {
|
|
const chartGroups = groupInvestigationCharts(charts);
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [keyboardAction, setKeyboardAction] = useState(false);
|
|
const reducedMotion = useReducedMotion();
|
|
const immediate = Boolean(reducedMotion) || keyboardAction;
|
|
const contentId = useId();
|
|
const reviewing = history?.selectedId != null;
|
|
const selected = history?.mergedEntry?.id === history?.selectedId
|
|
? history?.mergedEntry
|
|
: history?.entries.find((entry) => entry.id === history.selectedId);
|
|
const selectedLastId = selected?.mergedStepIds?.at(-1) ?? selected?.id;
|
|
const pending = selectedLastId !== undefined ? history!.entries.filter((entry) => entry.id > selectedLastId).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 || localPreview ? 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 ${localPreview ? `${collapsed ? "h-28" : "h-[min(480px,55dvh)]"} [&>[data-investigation-content]]:flex-1` : ""}`}
|
|
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
|
|
>
|
|
<motion.header
|
|
layout={immediate || localPreview ? false : "position"}
|
|
transition={presentationTransition(immediate)}
|
|
className={`@container shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
|
|
>
|
|
<div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
|
|
<div className={localPreview ? "contents" : "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 ${localPreview ? "col-span-2 row-start-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">
|
|
图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
|
|
</span>
|
|
{reviewing && pending > 0 ? (
|
|
<span role="status" className="text-[11px] text-muted-foreground">
|
|
后续 {pending} 个动作
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className={`flex shrink-0 items-center gap-1 ${localPreview ? "col-start-2 row-start-1" : ""}`}>
|
|
<InvestigationChartManager key={chartWindowScope ?? JSON.stringify([scope, task?.task_id, task?.execution_generation])}
|
|
groups={chartGroups} blocks={blocks} autoOpen={chartWindowAutoOpen} taskTitle={task?.title ?? title}
|
|
reviewing={reviewing} onCloseChart={onCloseChart} references={references} />
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className={`shrink-0 ${localPreview ? "col-start-2 row-start-1" : ""}`}
|
|
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>
|
|
</div>
|
|
<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} playback={collapsed ? undefined : history.playback} /> : null}
|
|
{selected?.mergedSummary ? <p aria-label="合并展示类别" className="sr-only">
|
|
合并内容:{selected.mergedSummary}
|
|
</p> : null}
|
|
</motion.header>
|
|
<AnimatePresence initial={false} mode="popLayout">
|
|
{!collapsed ? (
|
|
<PresentationViewport
|
|
key="presentation-content"
|
|
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}
|
|
{!reviewing && !localPreview && status === "connecting" && !charts.length && !blocks.length ? (
|
|
<LoadingSkeleton label="正在加载调查展示" />
|
|
) : 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 key={chartWindowScope} 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}
|
|
{chartGroups.map((group) => (
|
|
<PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
|
|
<InvestigationChartGroup group={group} reviewing={reviewing ?? false}
|
|
onCloseChart={onCloseChart} references={references} />
|
|
</PresentationItem>
|
|
))}
|
|
</AnimatePresence>
|
|
<PresentationBusinessResults
|
|
blocks={blocks}
|
|
records={records}
|
|
reviewing={reviewing}
|
|
onLocateTargets={onLocateTargets}
|
|
/>
|
|
{!charts.length &&
|
|
!overlay &&
|
|
!blocks.length &&
|
|
!records.length &&
|
|
status !== "connecting" &&
|
|
!relatedLayers.length ? (
|
|
<p className="text-xs leading-5 text-muted-foreground">
|
|
当前没有展示结果,可切换历史动作回看地图与结果。
|
|
</p>
|
|
) : null}
|
|
</PresentationViewport>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</motion.aside>
|
|
);
|
|
}
|