Files
next-tjwater-frontend/src/features/workbench/components/investigation-presentation-panel.tsx
T
jiang 03827a2fc0
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
feat(workbench): group live presentation steps into expandable categories
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.
2026-11-12 12:54:09 +08:00

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>
);
}