feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user