Files
next-tjwater-frontend/src/features/workbench/components/investigation-presentation-panel.tsx
T
jiang 8a63926284
Generic Container CI/CD / test-build-publish (push) Successful in 1m25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m26s
fix(charts): simplify time labels in investigation views
2026-11-07 21:03:43 +08:00

364 lines
16 KiB
TypeScript

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 { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
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 { FloatingWindow } from "@/shared/ui/floating-window";
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[];
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,
overlay,
cameraPaused,
status,
error,
width,
relatedLayers,
onResumeCamera,
onCloseChart,
onToggleOverlay,
onFitOverlay,
onToggleLayer
}: Props) {
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 chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
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 || 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={`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">
图表 {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 ${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>
<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}
</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}
<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={formatChartDisplayText(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]">
{formatChartDisplayText(item.chart.title)}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded />
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</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}
chartWindowKey={chartWindowKey}
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>
);
}