feat(charts): manage investigation charts in a scrolling workspace
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { useId, useLayoutEffect, useRef } from "react";
|
||||
import { mapOptionInteractionVariants } from "@/features/map/core";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
@@ -8,7 +9,7 @@ import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-sea
|
||||
|
||||
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
|
||||
|
||||
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) {
|
||||
function PlaceSummary({ item, index, annotation = item.district, interactive = false }: { item: PlaceSearchItem; index?: number; annotation?: string; interactive?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
|
||||
@@ -16,7 +17,7 @@ function PlaceSummary({ item, index, annotation = item.district }: { item: Place
|
||||
</span> : null}
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="flex items-start justify-between gap-2 leading-5">
|
||||
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span>
|
||||
<span className={cn("min-w-0 flex-1 break-words font-semibold", !interactive && "text-[var(--ink)]")}>{item.name}</span>
|
||||
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
|
||||
</span>
|
||||
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
|
||||
@@ -123,16 +124,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
|
||||
{results.items.map((item, index) => (
|
||||
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
|
||||
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft"
|
||||
<button type="button" aria-pressed={result?.id === item.id}
|
||||
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
|
||||
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]",
|
||||
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")}
|
||||
className={cn("flex min-h-16 w-full items-center justify-start gap-3 rounded-xl border border-transparent whitespace-normal px-1 py-3 text-left text-xs text-[var(--ink)]",
|
||||
mapOptionInteractionVariants({ state: result?.id === item.id ? "selected" : "default" }),
|
||||
result?.id === item.id && "border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]")}
|
||||
onClick={(event) => {
|
||||
geocoding.select(item.id);
|
||||
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
|
||||
}}>
|
||||
<PlaceSummary item={item} index={index} />
|
||||
</Button>
|
||||
<PlaceSummary item={item} index={index} interactive />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul> : null}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Check, FileText, X } from "lucide-react";
|
||||
import { AgentChartView } from "@/features/agent";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
|
||||
|
||||
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
|
||||
group: ChartGroup;
|
||||
tracked?: boolean;
|
||||
tile?: boolean;
|
||||
reviewing: boolean;
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const [plotHeight, setPlotHeight] = useState(220);
|
||||
useLayoutEffect(() => {
|
||||
if (tile) return;
|
||||
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
|
||||
if (!viewport || typeof ResizeObserver === "undefined") return;
|
||||
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [tile]);
|
||||
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
|
||||
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
|
||||
const multiple = group.items.length > 1;
|
||||
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
|
||||
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
|
||||
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
|
||||
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
|
||||
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
|
||||
</div>
|
||||
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
|
||||
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
|
||||
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
|
||||
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
|
||||
onClick={() => setSelectedId(item.command.chart_id)}>
|
||||
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
|
||||
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
|
||||
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
|
||||
</Button>)}
|
||||
</div>
|
||||
</aside>;
|
||||
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
|
||||
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
|
||||
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
|
||||
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div> : null}
|
||||
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
|
||||
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
|
||||
{multiple ? objectButtons(true) : null}
|
||||
</div>
|
||||
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
|
||||
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
|
||||
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
|
||||
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
|
||||
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
|
||||
</div>
|
||||
{multiple ? objectButtons() : null}
|
||||
</div>
|
||||
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
|
||||
</div>;
|
||||
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
|
||||
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
|
||||
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
|
||||
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
|
||||
<div className="min-w-0">
|
||||
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
|
||||
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
|
||||
</div>
|
||||
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
|
||||
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
|
||||
</Popover> : null}
|
||||
<FloatingWindow title={title} fillContent>{() => content(true)}</FloatingWindow>
|
||||
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
|
||||
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
|
||||
{content(tile, tile)}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { DragDropProvider, DragOverlay, type DragEndEvent } from "@dnd-kit/react";
|
||||
import { Columns2, GripVertical, LayoutGrid, LocateFixed, RotateCcw, Square } from "lucide-react";
|
||||
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { useChartWindowLayout } from "../hooks/use-chart-window-layout";
|
||||
import type { InvestigationChartGroup } from "../utils/investigation-chart-groups";
|
||||
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
|
||||
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
|
||||
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
|
||||
|
||||
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
|
||||
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
|
||||
|
||||
export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle, reviewing, onCloseChart, references }: {
|
||||
groups: InvestigationChartGroup[];
|
||||
blocks: AnswerUiBlock[];
|
||||
/** Transient live intent, never derived from restored chart data or history. */
|
||||
autoOpen: boolean;
|
||||
taskTitle: string;
|
||||
reviewing: boolean;
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const entries = useMemo<ManagedChart[]>(() => [
|
||||
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
|
||||
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
|
||||
], [groups, blocks]);
|
||||
const titles = useMemo(() => new Map(entries.map(({ id, title }) => [id, title])), [entries]);
|
||||
const arrangement = useChartWindowLayout(entries.map(({ id }) => id));
|
||||
const { layout, slots, columns } = arrangement;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [located, setLocated] = useState<{ id: string } | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const cards = useRef(new Map<string, HTMLElement>());
|
||||
const pendingLocation = useRef<string | null>(null);
|
||||
const helpId = useId();
|
||||
useEffect(() => {
|
||||
if (!located) return;
|
||||
const timer = window.setTimeout(() => setLocated(null), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [located]);
|
||||
const move = (id: string, target: number) => {
|
||||
arrangement.move(id, target);
|
||||
setAnnouncement(`${titles.get(id)},已移至位置 ${target + 1}`);
|
||||
};
|
||||
const drop = (event: DragEndEvent) => {
|
||||
const { source, target } = event.operation;
|
||||
if (!event.canceled && source && target && typeof target.data.slot === "number") move(String(source.id), target.data.slot);
|
||||
};
|
||||
const count = entries.length;
|
||||
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
|
||||
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent
|
||||
// A session gets one automatic request. Updates never revoke a user's dismissal.
|
||||
openRequestKey={autoOpen ? "live-charts" : undefined}>
|
||||
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<div className="shrink-0 space-y-2 border-b border-border pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
|
||||
<Select value="" onValueChange={(id) => { setLocated({ id }); pendingLocation.current = id; }} disabled={!count}>
|
||||
<SelectTrigger aria-label="快速定位图表" className="h-8 w-auto min-w-0 max-w-72 flex-[1_1_160px] gap-2 border-transparent bg-transparent px-2 text-xs shadow-none [--field-focus-border:transparent] hover:bg-background/70 focus-visible:ring-1 focus-visible:ring-border [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"><LocateFixed size={14} className="shrink-0" aria-hidden /><SelectValue placeholder="快速定位图表" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
const id = pendingLocation.current;
|
||||
pendingLocation.current = null;
|
||||
const card = id ? cards.current.get(id) : undefined;
|
||||
if (card) {
|
||||
event.preventDefault();
|
||||
card.scrollIntoView({ block: "start", inline: "nearest", behavior: "auto" });
|
||||
card.focus({ preventScroll: true });
|
||||
}
|
||||
}}>
|
||||
{slots.map((id, index) => id ? <SelectItem key={id} value={id} className="whitespace-normal text-xs [&>span:last-child]:break-words">位置 {index + 1} · {titles.get(id)}</SelectItem> : null)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div role="group" aria-label="图表排列方式" className="flex items-center gap-1">
|
||||
{layouts.map(({ id, label, icon: Icon }) => <Button key={id} type="button" variant={layout === id ? "secondary" : "ghost"} size="icon-sm"
|
||||
aria-label={label} title={label} aria-pressed={layout === id} onClick={() => arrangement.setLayout(id)}><Icon size={14} aria-hidden /></Button>)}
|
||||
</div>
|
||||
<span className="inline-flex border-l border-border pl-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs" aria-label="重置图表排列" disabled={!count}
|
||||
onClick={() => { arrangement.reset(); scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); setAnnouncement("已恢复默认图表位置"); }}><RotateCcw size={14} aria-hidden />重置</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id={helpId} className="sr-only">拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。</p>
|
||||
<p role="status" aria-live="polite" className="sr-only">{announcement}</p>
|
||||
<DragDropProvider onDragEnd={drop}>
|
||||
<div ref={scrollRef} tabIndex={0} aria-label="图表滚动区" className="min-h-0 flex-1 overflow-auto overscroll-contain p-0.5" style={{ containerType: "size", scrollbarGutter: "stable" }} data-chart-manager-scroll="">
|
||||
{count ? <div data-chart-layout={layout} className={`isolate grid min-h-full min-w-0 content-start gap-3 ${layout === "four" ? "grid-cols-2 auto-rows-[max(256px,calc((100cqh-12px)/2))]" : layout === "two" ? "grid-cols-2 auto-rows-[max(256px,100cqh)]" : "grid-cols-1 auto-rows-[max(256px,100cqh)]"}`}>
|
||||
{slots.map((id, index) => <ChartDropSlot key={index} index={index} columns={columns} occupied={Boolean(id)} />)}
|
||||
{entries.map((entry) => {
|
||||
const index = slots.indexOf(entry.id);
|
||||
return <ManagedChartSlot key={entry.id} id={entry.id} title={entry.title} index={index} columns={columns}
|
||||
located={located?.id === entry.id} helpId={helpId}
|
||||
register={(id, node) => { if (node) cards.current.set(id, node); else cards.current.delete(id); }}>
|
||||
{entry.group ? <ChartGroup group={entry.group} tile tracked={false} reviewing={reviewing} onCloseChart={onCloseChart} references={references} /> : <AnswerUiView block={entry.block} chartPresentation="tile" disabled={reviewing} />}
|
||||
</ManagedChartSlot>;
|
||||
})}
|
||||
</div> : <p role="status" className="py-8 text-center text-sm text-muted-foreground">当前没有图表,新的图表将在这里更新。</p>}
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null} style={{ position: "absolute", zIndex: 100 }}>
|
||||
{(source) => <div data-chart-drag-preview="" className="workbench-shadcn-surface flex max-w-80 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground shadow-xs"><GripVertical size={14} className="shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 break-words">{String(source.data.title)}</span></div>}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</section>}
|
||||
</FloatingWindow>
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/react";
|
||||
import { pointerIntersection } from "@dnd-kit/collision";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
function chartSlotPosition(index: number, columns: number): CSSProperties {
|
||||
return { gridRow: Math.floor(index / columns) + 1, gridColumn: index % columns + 1 };
|
||||
}
|
||||
|
||||
export function ChartDropSlot({ index, columns, occupied }: { index: number; columns: number; occupied: boolean }) {
|
||||
const { ref, isDropTarget } = useDroppable({ id: `slot:${index}`, type: "slot", accept: "chart", data: { slot: index }, collisionDetector: pointerIntersection });
|
||||
return <div ref={ref} data-chart-slot={index} style={chartSlotPosition(index, columns)}
|
||||
className={`pointer-events-none relative flex min-h-0 min-w-0 items-center justify-center rounded-xl border border-dashed ${isDropTarget ? "z-10 border-primary/40 bg-primary/5 ring-1 ring-primary/15 ring-inset" : occupied ? "border-transparent" : "border-border bg-muted/20"}`}>
|
||||
{!occupied ? <p className="text-center text-xs leading-6 text-muted-foreground"><span className="block tabular-nums">位置 {index + 1}</span>拖动图表到这里</p> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The chart stays under the same grid parent when its CSS position changes. */
|
||||
export function ManagedChartSlot({ id, title, index, columns, located, helpId, register, children }: {
|
||||
id: string;
|
||||
title: string;
|
||||
index: number;
|
||||
columns: number;
|
||||
located: boolean;
|
||||
helpId: string;
|
||||
register: (id: string, element: HTMLElement | null) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { ref, handleRef, isDragSource } = useDraggable({ id, type: "chart", data: { title } });
|
||||
return <article ref={(node) => { ref(node); register(id, node); }} tabIndex={-1}
|
||||
data-managed-chart={id} data-chart-title={title} data-slot-index={index} data-chart-located={located} data-chart-dragging={isDragSource}
|
||||
aria-label={`图表窗口 ${title}`} style={chartSlotPosition(index, columns)}
|
||||
className="group/chart-slot relative z-0 flex min-h-0 min-w-0 flex-col rounded-xl outline-hidden">
|
||||
<div className={`flex h-9 shrink-0 items-center rounded-lg ${located ? "bg-primary/10" : ""}`}>
|
||||
<button ref={handleRef} type="button" data-slot="button" className={`flex h-full w-full touch-none select-none items-center justify-start gap-1 rounded-lg border border-transparent px-2 text-xs font-normal outline-hidden focus-visible:outline-1 focus-visible:outline-ring focus-visible:-outline-offset-1 active:cursor-grabbing ${isDragSource ? "cursor-grabbing bg-muted text-foreground" : "cursor-grab bg-transparent text-muted-foreground [@media(hover:hover)]:hover:bg-muted/70 [@media(hover:hover)]:hover:text-foreground"}`}
|
||||
aria-label={`拖动图表 ${title}`} aria-describedby={helpId}>
|
||||
<GripVertical size={14} aria-hidden /><span className="tabular-nums">位置 {index + 1}</span>
|
||||
{located ? <span aria-hidden="true" className="ml-auto shrink-0 text-xs font-medium text-primary">已定位</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1">{children}</div>
|
||||
<div aria-hidden="true" data-chart-hover-highlight="" className={`pointer-events-none absolute inset-0 z-10 rounded-xl bg-primary/[0.04] ${isDragSource ? "opacity-100" : "opacity-0 [@media(hover:hover)]:group-hover/chart-slot:opacity-100"}`} />
|
||||
</article>;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
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 { 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";
|
||||
@@ -19,7 +18,9 @@ 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 { 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";
|
||||
@@ -36,6 +37,8 @@ type Props = {
|
||||
task: OperationalTask | null;
|
||||
localPreview?: boolean;
|
||||
charts: InvestigationChart[];
|
||||
chartWindowAutoOpen?: boolean;
|
||||
chartWindowScope?: string;
|
||||
overlay: InvestigationOverlay | null;
|
||||
cameraPaused: boolean;
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error";
|
||||
@@ -68,6 +71,8 @@ export function InvestigationPresentationPanel({
|
||||
onLocateTargets,
|
||||
localPreview = false,
|
||||
charts,
|
||||
chartWindowAutoOpen = false,
|
||||
chartWindowScope,
|
||||
overlay,
|
||||
cameraPaused,
|
||||
status,
|
||||
@@ -80,13 +85,13 @@ export function InvestigationPresentationPanel({
|
||||
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 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 ? "本地预览展示" : "调查实时展示";
|
||||
@@ -121,7 +126,7 @@ export function InvestigationPresentationPanel({
|
||||
<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"}`}
|
||||
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"}>
|
||||
@@ -137,7 +142,7 @@ export function InvestigationPresentationPanel({
|
||||
: statusLabels[status]}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
|
||||
图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
|
||||
</span>
|
||||
{reviewing && pending > 0 ? (
|
||||
<span role="status" className="text-[11px] text-muted-foreground">
|
||||
@@ -146,6 +151,10 @@ export function InvestigationPresentationPanel({
|
||||
) : 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"
|
||||
@@ -161,6 +170,7 @@ export function InvestigationPresentationPanel({
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
@@ -242,7 +252,7 @@ export function InvestigationPresentationPanel({
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<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">
|
||||
@@ -291,51 +301,10 @@ export function InvestigationPresentationPanel({
|
||||
</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>
|
||||
{chartGroups.map((group) => (
|
||||
<PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
|
||||
<InvestigationChartGroup group={group} reviewing={reviewing ?? false}
|
||||
onCloseChart={onCloseChart} references={references} />
|
||||
</PresentationItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -343,7 +312,6 @@ export function InvestigationPresentationPanel({
|
||||
blocks={blocks}
|
||||
records={records}
|
||||
reviewing={reviewing}
|
||||
chartWindowKey={chartWindowKey}
|
||||
onLocateTargets={onLocateTargets}
|
||||
/>
|
||||
{!charts.length &&
|
||||
|
||||
@@ -7,7 +7,6 @@ type Props = {
|
||||
blocks: AnswerUiBlock[];
|
||||
records: Array<Record<string, unknown>>;
|
||||
reviewing: boolean;
|
||||
chartWindowKey?: string;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -15,7 +14,6 @@ export function PresentationBusinessResults({
|
||||
blocks,
|
||||
records,
|
||||
reviewing,
|
||||
chartWindowKey,
|
||||
onLocateTargets
|
||||
}: Props) {
|
||||
if (!blocks.length && !records.length) return null;
|
||||
@@ -26,7 +24,6 @@ export function PresentationBusinessResults({
|
||||
key={block.block_id}
|
||||
block={block}
|
||||
disabled={reviewing}
|
||||
chartWindowKey={chartWindowKey}
|
||||
onLocate={
|
||||
onLocateTargets && !reviewing
|
||||
? (target) =>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
||||
import {
|
||||
mapOptionInteractionVariants,
|
||||
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
@@ -53,11 +55,12 @@ const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
const alertReadableRowClassName =
|
||||
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
|
||||
const alertActionClassName = cn(
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95"
|
||||
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
|
||||
mapOptionInteractionVariants()
|
||||
);
|
||||
|
||||
export function AnalysisViewMenu({
|
||||
@@ -243,7 +246,7 @@ export function AlertMenu({
|
||||
tone === "warning" ? "status-tone-warning" : "status-tone-danger",
|
||||
"relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
alertReadableRowClassName
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
@@ -397,8 +400,8 @@ export function UserMenu({
|
||||
onSelect={onLogout ? () => void onLogout() : undefined}
|
||||
className={cn(
|
||||
"px-2 py-2",
|
||||
!onLogout && "text-[var(--ink-muted)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
|
||||
!onLogout && "text-[var(--ink-muted)]"
|
||||
)}
|
||||
>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
@@ -482,7 +485,7 @@ function MenuPlainButton({
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
|
||||
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
||||
: cn(MAP_ACTION_ROW_INTERACTION_CLASS_NAME, "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]")
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-xs font-semibold leading-4">{label}</span>
|
||||
@@ -512,7 +515,7 @@ function MenuPrimaryButton({
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
disabled
|
||||
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
|
||||
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
|
||||
: cn("border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20", mapOptionInteractionVariants({ state: "primary" }))
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
@@ -570,9 +573,8 @@ function MenuAction({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"my-0.5 items-center border border-transparent px-2 py-2 transition",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
"my-0.5 items-center px-2 py-2 transition-colors",
|
||||
MAP_ACTION_ROW_INTERACTION_CLASS_NAME
|
||||
)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
@@ -582,13 +584,13 @@ function MenuAction({
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
||||
tone === "warning" && "status-icon status-tone-warning",
|
||||
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600"
|
||||
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)]"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
|
||||
<span className="block text-sm font-semibold leading-5">{label}</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
|
||||
import { mapOptionInteractionVariants } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
export const headerControlButtonClassName =
|
||||
cn(
|
||||
buttonInteractionVariants({ tone: "neutral" }),
|
||||
mapOptionInteractionVariants(),
|
||||
"data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--surface-reading)] data-[state=open]:text-[var(--action-blue-hover)]",
|
||||
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
|
||||
);
|
||||
|
||||
@@ -11,6 +14,6 @@ export const compactHeaderControlButtonClassName =
|
||||
"h-10 w-10 justify-center px-0";
|
||||
|
||||
export const headerControlIconClassName =
|
||||
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-[var(--ink-muted)] transition-colors group-hover:bg-[var(--action-soft)] group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:bg-[var(--action-soft)] group-data-[state=open]:text-[var(--action-blue-hover)]";
|
||||
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-inherit";
|
||||
|
||||
export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Box,
|
||||
CalendarClock,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
|
||||
import type {
|
||||
WorkbenchAlert,
|
||||
WorkbenchAlertDataSource,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
} from "../types";
|
||||
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
|
||||
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
|
||||
import { WorkbenchWindowMenu } from "./workbench-window-menu";
|
||||
import {
|
||||
compactHeaderTextClassName,
|
||||
headerControlButtonClassName,
|
||||
@@ -63,7 +65,7 @@ export type WorkbenchTopBarProps = {
|
||||
onLogout?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user";
|
||||
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user" | "windows";
|
||||
|
||||
export function WorkbenchTopBar({
|
||||
dataTime,
|
||||
@@ -101,12 +103,13 @@ export function WorkbenchTopBar({
|
||||
onLogout
|
||||
}: WorkbenchTopBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||
const hasWindows = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries).length > 0;
|
||||
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
||||
setOpenMenu(open ? menuId : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
|
||||
<header data-floating-window-inset-top="" className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -116,7 +119,7 @@ export function WorkbenchTopBar({
|
||||
>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={cn("min-w-0", hasWindows && "max-[399px]:sr-only")}>
|
||||
<h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
|
||||
<span className="sm:hidden">供水</span>
|
||||
<span className="hidden sm:inline">供水管网智慧运维系统</span>
|
||||
@@ -172,6 +175,7 @@ export function WorkbenchTopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<WorkbenchWindowMenu open={openMenu === "windows"} onOpenChange={createMenuOpenChange("windows")} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="项目分析成果"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useRef, useSyncExternalStore } from "react";
|
||||
import { AppWindow, Check, PanelsTopLeft, X } from "lucide-react";
|
||||
import { mapOptionInteractionVariants, MAP_ACTION_ROW_INTERACTION_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
|
||||
import { headerControlButtonClassName, headerControlIconClassName } from "./workbench-top-bar-styles";
|
||||
|
||||
export function WorkbenchWindowMenu({ open, onOpenChange }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const entries = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const afterClose = useRef<(() => void) | null>(null);
|
||||
if (!entries.length) return null;
|
||||
return <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button ref={trigger} type="button" aria-label={`窗口管理,当前 ${entries.length} 个窗口`} title="窗口管理"
|
||||
className={cn("group relative px-1.5 sm:px-2", headerControlButtonClassName)}>
|
||||
<span className={headerControlIconClassName}><PanelsTopLeft size={14} aria-hidden /></span>
|
||||
<span className="hidden text-xs xl:inline">窗口</span>
|
||||
<span aria-hidden className="absolute -right-0.5 -top-0.5 grid min-w-3.5 place-items-center rounded-full bg-[var(--action-blue)] px-0.5 text-[9px] leading-3.5 text-white tabular-nums xl:static xl:min-w-4 xl:px-1 xl:text-xs xl:leading-4">{entries.length}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" aria-label="已打开的窗口" aria-labelledby={undefined}
|
||||
className={cn("acrylic-panel z-[90] w-80 max-w-[calc(100vw-24px)] border p-2 text-slate-900", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
const focus = afterClose.current;
|
||||
afterClose.current = null;
|
||||
if (focus) { event.preventDefault(); focus(); }
|
||||
}}>
|
||||
<DropdownMenuLabel className="flex items-center justify-between px-2 py-1.5 text-xs text-[var(--ink-muted)]">
|
||||
<span>已打开的窗口</span><span className="tabular-nums">{entries.length} 个</span>
|
||||
</DropdownMenuLabel>
|
||||
{[...entries].reverse().map((entry, index) => <DropdownMenuItem key={entry.id}
|
||||
aria-label={`定位窗口 ${entry.title}`} aria-current={index === 0 ? "true" : undefined}
|
||||
className={cn("min-h-10 items-start px-2 py-2.5 text-xs", index === 0
|
||||
? cn("rounded-xl border border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] focus:bg-[var(--action-selection-soft)] focus:text-[var(--action-blue-hover)]", mapOptionInteractionVariants({ state: "selected" }))
|
||||
: MAP_ACTION_ROW_INTERACTION_CLASS_NAME)}
|
||||
onSelect={() => {
|
||||
afterClose.current = () => entry.focus();
|
||||
onOpenChange(false);
|
||||
}}>
|
||||
<AppWindow size={15} className="mt-0.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 break-words leading-5">{entry.title}</span>
|
||||
{index === 0 ? <Check size={14} className="mt-0.5 shrink-0 text-[var(--action-blue)]" aria-hidden /> : null}
|
||||
</DropdownMenuItem>)}
|
||||
<DropdownMenuSeparator className="my-1.5 bg-[var(--surface-divider)]/70" />
|
||||
<DropdownMenuItem className={cn("min-h-10 px-2 text-xs", MAP_ACTION_ROW_INTERACTION_CLASS_NAME)} onSelect={() => {
|
||||
const fallback = trigger.current?.closest("header")?.querySelector<HTMLButtonElement>('button[aria-label="打开用户菜单"]');
|
||||
afterClose.current = () => fallback?.focus({ preventScroll: true });
|
||||
onOpenChange(false);
|
||||
[...entries].reverse().forEach((entry) => entry.close());
|
||||
}}><X size={15} aria-hidden />关闭全部窗口</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartLayout, type ChartSlot } from "../utils/chart-window-slots";
|
||||
|
||||
/** Keep chart identities in fixed positions throughout the scrolling grid. */
|
||||
export function useChartWindowLayout(ids: string[]) {
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
const [narrow, setNarrow] = useState(window.innerWidth < 760);
|
||||
const [preferredLayout, setPreferredLayout] = useState<ChartLayout>("four");
|
||||
const layout = narrow ? "one" : preferredLayout;
|
||||
const columns = columnsForLayout(layout);
|
||||
const [slots, setSlots] = useState<ChartSlot[]>([]);
|
||||
const available = reconcileChartSlots(slots, ids, columns);
|
||||
if (available !== slots) setSlots(available);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!container) return;
|
||||
const measure = () => setNarrow(container.clientWidth < 720);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [container]);
|
||||
|
||||
return {
|
||||
containerRef: setContainer,
|
||||
layout,
|
||||
preferredLayout,
|
||||
narrow,
|
||||
setLayout: setPreferredLayout,
|
||||
slots: available,
|
||||
columns,
|
||||
move: (id: string, target: number) => {
|
||||
const source = available.indexOf(id);
|
||||
const next = swapChartSlots(available, source, target);
|
||||
if (next !== available) {
|
||||
setSlots(next);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
setSlots(reconcileChartSlots([], ids, columns));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -95,3 +95,59 @@ it("advances saved snapshots on its timer, ignores new results and stops when th
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(result.current.records).toEqual([{ value: 3 }]);
|
||||
});
|
||||
|
||||
it("requests only the live task manager and forgets automatic intent across task switches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const task = { task_id: "task-a", title: "运行中调查", execution_generation: 1, status: "running", evidence: [] } as unknown as import("../api/operational-task-client").OperationalTask;
|
||||
const command = { action: "open_chart" as const, chart_id: "pressure", evidence_refs: ["test"], chart: {
|
||||
title: "压力", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "压力", data: [20] }]
|
||||
} };
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.presentationScope = "investigation";
|
||||
const marker = document.createElement("div");
|
||||
marker.dataset.investigationChart = "pressure";
|
||||
marker.getBoundingClientRect = () => ({ height: 10 }) as DOMRect;
|
||||
panel.append(marker);
|
||||
document.body.append(panel);
|
||||
const initial = { ...options, scope: "investigation" as const, task, enabled: true };
|
||||
const { result, rerender } = renderHook((next) => useInvestigationPresentation(next), { initialProps: initial });
|
||||
const execute = async (origin: "live" | "preview" | "chat") => {
|
||||
await act(async () => {
|
||||
const pending = result.current.executeCommand(command, new AbortController().signal, origin);
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
await pending;
|
||||
});
|
||||
};
|
||||
try {
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
await execute("preview");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
await execute("chat");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
panel.dataset.investigationCollapsed = "true";
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
panel.dataset.investigationCollapsed = "false";
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(true);
|
||||
rerender({ ...initial, task: { ...task, task_id: "task-b" } });
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender(initial);
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender({ ...initial, enabled: false });
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender({ ...initial, task: { ...task, status: "completed", lifecycle_status: "completed" } });
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender(initial);
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "历史结果" }], commands: [] }));
|
||||
act(() => result.current.history.onSelect(result.current.history.entries[0].id));
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
act(() => result.current.history.onSelect(null));
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
} finally {
|
||||
panel.remove();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,6 +114,9 @@ async function waitForChart(
|
||||
}
|
||||
|
||||
export function useInvestigationPresentation(options: Options) {
|
||||
const windowScope = JSON.stringify([options.scope, options.scopeId, options.projectId, options.task?.task_id, options.task?.execution_generation]);
|
||||
const [chartWindow, setChartWindow] = useState({ scope: windowScope, requested: false });
|
||||
if (chartWindow.scope !== windowScope) setChartWindow({ scope: windowScope, requested: false });
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
@@ -155,6 +158,14 @@ export function useInvestigationPresentation(options: Options) {
|
||||
: "live",
|
||||
[]
|
||||
);
|
||||
const requestChartWindow = useCallback((origin: InvestigationPresentationActivity["source"]) => {
|
||||
const current = valuesRef.current;
|
||||
if (storeRef.current.selectedId !== null || current.scope === "chat" || current.scope === "preview") return;
|
||||
if (origin !== "live" || !current.enabled || current.task?.status !== "running") return;
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${current.scope ?? "investigation"}"]`);
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
setChartWindow({ scope: JSON.stringify([current.scope, current.scopeId, current.projectId, current.task?.task_id, current.task?.execution_generation]), requested: true });
|
||||
}, []);
|
||||
const update = useCallback(
|
||||
(updater: (view: PresentationView) => PresentationView) => {
|
||||
storeRef.current.update(updater(storeRef.current.current));
|
||||
@@ -289,6 +300,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (command.action === "open_chart") requestChartWindow(origin);
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
@@ -327,7 +339,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
[scene, publish, update]
|
||||
[scene, publish, update, requestChartWindow]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -555,6 +567,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const visible = storeRef.current.visible;
|
||||
const reviewing = storeRef.current.selectedId !== null;
|
||||
return {
|
||||
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
|
||||
chartWindowScope: windowScope,
|
||||
charts: visible.charts,
|
||||
blocks: visible.blocks,
|
||||
records: visible.records,
|
||||
|
||||
@@ -1477,6 +1477,7 @@ function MapWorkbenchContent({
|
||||
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
|
||||
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
|
||||
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
|
||||
chartWindowAutoOpen={surface.chartWindowAutoOpen} chartWindowScope={surface.chartWindowScope}
|
||||
status={scope === "chat" ? "live" : surface.status} error={surface.error}
|
||||
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
|
||||
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartSlot } from "./chart-window-slots";
|
||||
|
||||
describe("fixed chart slots", () => {
|
||||
it("maps supported layouts to grid columns", () => {
|
||||
expect([columnsForLayout("one"), columnsForLayout("two"), columnsForLayout("four")]).toEqual([1, 2, 2]);
|
||||
});
|
||||
|
||||
it("keeps chart positions when publications update in another order", () => {
|
||||
const slots: ChartSlot[] = ["flow", null, "pressure", "plans"];
|
||||
expect(reconcileChartSlots(slots, ["plans", "pressure", "flow"], 2)).toBe(slots);
|
||||
});
|
||||
|
||||
it("leaves deleted positions vacant without moving surviving charts", () => {
|
||||
expect(reconcileChartSlots(["pressure", "flow", "plans", null], ["pressure", "plans"], 2))
|
||||
.toEqual(["pressure", null, "plans", null]);
|
||||
});
|
||||
|
||||
it("fills the earliest vacant slots before appending a padded row", () => {
|
||||
expect(reconcileChartSlots(["pressure", null, "flow", null], ["new-1", "flow", "new-2", "pressure", "new-3"], 2))
|
||||
.toEqual(["pressure", "new-1", "flow", "new-2", "new-3", null]);
|
||||
});
|
||||
|
||||
it("uses vacancies created by a deletion in the same update", () => {
|
||||
expect(reconcileChartSlots(["pressure", "removed", "flow", null], ["pressure", "flow", "new"], 2))
|
||||
.toEqual(["pressure", "new", "flow", null]);
|
||||
});
|
||||
|
||||
it("preserves linear positions when the column count changes", () => {
|
||||
const slots: ChartSlot[] = ["a", null, "b", "c", "d", null];
|
||||
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 1)).toEqual(["a", null, "b", "c", "d"]);
|
||||
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 2)).toBe(slots);
|
||||
});
|
||||
|
||||
it("trims only empty trailing rows, retaining leading and middle vacancies", () => {
|
||||
expect(reconcileChartSlots([null, null, "a", null, null, null, null, null], ["a"], 2))
|
||||
.toEqual([null, null, "a", null]);
|
||||
expect(reconcileChartSlots(["a", null, null, null], ["a"], 2)).toEqual(["a", null]);
|
||||
expect(reconcileChartSlots(["a", null, null, null], [], 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds default slots in available order and never duplicates a chart", () => {
|
||||
expect(reconcileChartSlots([], ["a", "b", "a"], 2)).toEqual(["a", "b"]);
|
||||
expect(reconcileChartSlots(["a", "a", "b", null], ["a", "b", "c"], 2)).toEqual(["a", "c", "b", null]);
|
||||
});
|
||||
|
||||
it("swaps occupied slots without inserting or shifting adjacent charts", () => {
|
||||
const slots: ChartSlot[] = ["a", "b", "c", "d"];
|
||||
expect(swapChartSlots(slots, 0, 2)).toEqual(["c", "b", "a", "d"]);
|
||||
expect(slots).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("swaps across rows and permits an empty destination", () => {
|
||||
expect(swapChartSlots(["a", "b", "c", "d", "e", null, null, null], 0, 5))
|
||||
.toEqual([null, "b", "c", "d", "e", "a", null, null]);
|
||||
});
|
||||
|
||||
it("keeps the original array for invalid or unchanged swaps", () => {
|
||||
const slots: ChartSlot[] = ["a", null, null];
|
||||
for (const [source, target] of [[-1, 1], [0, 3], [3, 0], [0.5, 1], [0, Number.NaN], [0, 0], [1, 2]]) {
|
||||
expect(swapChartSlots(slots, source, target)).toBe(slots);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ChartSlot = string | null;
|
||||
export type ChartLayout = "one" | "two" | "four";
|
||||
|
||||
export function columnsForLayout(layout: ChartLayout): 1 | 2 {
|
||||
return layout === "one" ? 1 : 2;
|
||||
}
|
||||
|
||||
/** Retain fixed positions, fill vacancies, and pad the final row without compacting. */
|
||||
export function reconcileChartSlots(slots: ChartSlot[], availableIds: string[], columns: 1 | 2): ChartSlot[] {
|
||||
const available = new Set(availableIds);
|
||||
const retained = new Set<string>();
|
||||
const next = slots.map((id) => {
|
||||
if (id === null || !available.has(id) || retained.has(id)) return null;
|
||||
retained.add(id);
|
||||
return id;
|
||||
});
|
||||
for (const id of available) {
|
||||
if (retained.has(id)) continue;
|
||||
const empty = next.indexOf(null);
|
||||
if (empty === -1) next.push(id);
|
||||
else next[empty] = id;
|
||||
}
|
||||
let last = next.length - 1;
|
||||
while (last >= 0 && next[last] === null) last -= 1;
|
||||
const length = Math.ceil((last + 1) / columns) * columns;
|
||||
if (next.length > length) next.length = length;
|
||||
while (next.length < length) next.push(null);
|
||||
return next.length === slots.length && next.every((id, index) => id === slots[index]) ? slots : next;
|
||||
}
|
||||
|
||||
/** Exchange two fixed positions, including vacant destinations; never shift neighbors. */
|
||||
export function swapChartSlots(slots: ChartSlot[], sourceIndex: number, targetIndex: number): ChartSlot[] {
|
||||
if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex) || sourceIndex < 0 || targetIndex < 0 ||
|
||||
sourceIndex >= slots.length || targetIndex >= slots.length || slots[sourceIndex] === slots[targetIndex]) return slots;
|
||||
const next = [...slots];
|
||||
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupInvestigationCharts } from "./investigation-chart-groups";
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
const comparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16"): InvestigationChart => ({
|
||||
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`evidence:${id}`],
|
||||
chart: { title: `${id} ${metric}七日同时刻对照(${unit})`, chart_type: "time_comparison",
|
||||
x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: ["07:45", "08:00"],
|
||||
series: [
|
||||
{ name: `${id} 当前 ${date}`, type: "line", data: [30, 25], sample_times: [`${date}T07:45:00+08:00`, `${date}T08:00:00+08:00`] },
|
||||
{ name: `${id} 2026-07-09`, type: "scatter", data: [null, 32], sample_times: [null, "2026-07-09T08:00:00+08:00"] }
|
||||
] }
|
||||
});
|
||||
|
||||
const legacyComparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16") => {
|
||||
const command = comparison(id, metric, unit, date);
|
||||
command.chart.title = `${id} ${metric}三小时趋势与七天同期对照(${unit})`;
|
||||
command.chart.series[0].name = `${id} ${date.slice(5)}(${unit})`;
|
||||
command.chart.series[1].name = "07-09";
|
||||
return command;
|
||||
};
|
||||
|
||||
describe("investigation comparison groups", () => {
|
||||
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
|
||||
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
|
||||
expect(groups[1].items.map((item) => item.label)).toEqual(["542017", "486033"]);
|
||||
expect(groups[0].title).toBe("压力七日同时刻对照(m)");
|
||||
expect(groups[0].items[1].command).toBe(charts[1]);
|
||||
expect(charts[1].chart.series[1].data).toEqual([null, 32]);
|
||||
});
|
||||
|
||||
it("groups saved pressure and flow charts whose titles put the metric before the object ID", () => {
|
||||
const charts = [["170486", "压力", "m"], ["175618", "压力", "m"], ["541061", "流量", "m³/h"], ["542017", "流量", "m³/h"]].map(([id, metric, unit]) => {
|
||||
const command = comparison(id, metric, unit);
|
||||
command.chart.title = `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`;
|
||||
return command;
|
||||
});
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups.map((group) => ({ title: group.title, objects: group.items.map((item) => item.label) }))).toEqual([
|
||||
{ title: "压力当前曲线与前七天同期对比(m)", objects: ["170486", "175618"] },
|
||||
{ title: "流量当前曲线与前七天同期对比(m³/h)", objects: ["541061", "542017"] }
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes the same explicit object comparison in all three saved title formats", () => {
|
||||
const charts = [comparison("170486"), comparison("175618"), comparison("159673")];
|
||||
charts[0].chart.title = "170486 压力三小时与历史对照(m)";
|
||||
charts[1].chart.title = "测点 175618 压力三小时与历史对照(m)";
|
||||
charts[2].chart.title = "压力测点 159673 三小时与历史对照(m)";
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].title).toBe("压力三小时与历史对照(m)");
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618", "159673"]);
|
||||
});
|
||||
|
||||
it("groups saved date-only historical labels without rewriting commands, readings or evidence", () => {
|
||||
const charts = [legacyComparison("170486"), legacyComparison("175618")];
|
||||
charts[1].chart.series[1].data[1] = 0;
|
||||
const original = structuredClone(charts);
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
|
||||
groups[0].items.forEach((item, index) => {
|
||||
expect(item.command).toBe(charts[index]);
|
||||
expect(item.command.chart.series).toBe(charts[index].chart.series);
|
||||
expect(item.command.chart.series[1].sample_times).toBe(charts[index].chart.series[1].sample_times);
|
||||
expect(item.command.evidence_refs).toBe(charts[index].evidence_refs);
|
||||
});
|
||||
expect(charts).toEqual(original);
|
||||
});
|
||||
|
||||
it("keeps legacy comparisons with different units, windows or current dates separate", () => {
|
||||
const shifted = legacyComparison("175618");
|
||||
shifted.chart.x_data = ["08:00", "08:15"];
|
||||
shifted.chart.series[0].sample_times = ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"];
|
||||
shifted.chart.series[1].sample_times = [null, "2026-07-09T08:15:00+08:00"];
|
||||
const charts = [legacyComparison("170486"), legacyComparison("P2", "压力", "MPa"), shifted, legacyComparison("P3", "压力", "m", "2026-07-17")];
|
||||
expect(groupInvestigationCharts(charts)).toHaveLength(charts.length);
|
||||
});
|
||||
|
||||
it("leaves ambiguous or mixed-object legacy charts and unproven historical dates separate", () => {
|
||||
const ambiguous = legacyComparison("P2"); ambiguous.chart.title = "压力测点历史对照";
|
||||
const foreign = legacyComparison("P3"); foreign.chart.series[1].name = "P4 07-09";
|
||||
const mixed = legacyComparison("P4"); mixed.chart.series.push({ ...mixed.chart.series[0], name: "P5 当前 2026-07-16" });
|
||||
const missing = legacyComparison("P5"); delete missing.chart.series[1].sample_times;
|
||||
const mismatched = legacyComparison("P6"); mismatched.chart.series[1].sample_times = [null, "2026-07-10T08:00:00+08:00"];
|
||||
const empty = legacyComparison("P7"); empty.chart.series[1].data = [null, null]; empty.chart.series[1].sample_times = [null, null];
|
||||
const sameDateInUtc8 = legacyComparison("P8"); sameDateInUtc8.chart.series[1].sample_times = [null, "2026-07-09T00:00:00Z"];
|
||||
const charts = [legacyComparison("170486"), ambiguous, foreign, mixed, missing, mismatched, empty, sameDateInUtc8];
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(charts.length - 1);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "P8"]);
|
||||
});
|
||||
|
||||
it("keeps incompatible units, sampling windows, comparison dates and ordinary trends separate", () => {
|
||||
const base = comparison("170486");
|
||||
const window = comparison("175618"); window.chart.x_data = ["08:00", "08:15"];
|
||||
const trend = comparison("159673"); trend.chart.series = [trend.chart.series[0]];
|
||||
expect(groupInvestigationCharts([base, comparison("P2", "压力", "MPa"), comparison("P3", "压力", "m", "2026-07-17"), window, trend])).toHaveLength(5);
|
||||
const noTimes = [comparison("P4"), comparison("P5", "压力", "m", "2026-07-17")];
|
||||
for (const command of noTimes) for (const series of command.chart.series) delete series.sample_times;
|
||||
expect(groupInvestigationCharts(noTimes)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps group identity stable as objects arrive or are closed and leaves ambiguous titles alone", () => {
|
||||
const first = comparison("170486"), second = comparison("175618");
|
||||
expect(groupInvestigationCharts([first])[0].id).toBe(groupInvestigationCharts([second, first])[0].id);
|
||||
const ambiguous = comparison("159673"); ambiguous.chart.title = "压力同期对照";
|
||||
expect(groupInvestigationCharts([first, ambiguous])).toHaveLength(2);
|
||||
expect(groupInvestigationCharts([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
export type InvestigationChartGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Array<{ label: string; command: InvestigationChart }>;
|
||||
};
|
||||
|
||||
type ChartSeries = InvestigationChart["chart"]["series"][number];
|
||||
|
||||
/** Saved date-only legends identify a day only when their actual observations prove it. */
|
||||
function matchesHistoricalDate(series: ChartSeries) {
|
||||
if (!/^(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/.test(series.name) ||
|
||||
!series.sample_times || series.sample_times.length !== series.data.length) return false;
|
||||
let observedDate: string | null = null;
|
||||
for (let index = 0; index < series.data.length; index++) {
|
||||
const at = series.sample_times[index];
|
||||
if (series.data[index] === null) {
|
||||
if (at !== null) return false;
|
||||
continue;
|
||||
}
|
||||
if (typeof at !== "string") return false;
|
||||
const time = Date.parse(at);
|
||||
if (!Number.isFinite(time)) return false;
|
||||
const date = new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
|
||||
if (date.slice(5) !== series.name || observedDate !== null && observedDate !== date) return false;
|
||||
observedDate = date;
|
||||
}
|
||||
return observedDate !== null;
|
||||
}
|
||||
|
||||
function matchesObjectSeries(command: InvestigationChart, id: string) {
|
||||
const { chart } = command;
|
||||
const prefixed = (series: ChartSeries) => series.name.startsWith(`${id} `);
|
||||
if (chart.series.every(prefixed)) return true;
|
||||
const current = chart.series.filter((series) => series.type !== "scatter");
|
||||
return current.length === 1 && prefixed(current[0]) &&
|
||||
(current[0].type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)) === "line" &&
|
||||
chart.series.every((series) => prefixed(series) || series.type === "scatter" && matchesHistoricalDate(series));
|
||||
}
|
||||
|
||||
/** Group only explicitly named objects with the same comparison, axes and sampling window. */
|
||||
export function groupInvestigationCharts(charts: InvestigationChart[]): InvestigationChartGroup[] {
|
||||
const groups = new Map<string, InvestigationChartGroup>();
|
||||
for (const command of charts) {
|
||||
const { chart } = command;
|
||||
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
|
||||
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
|
||||
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
|
||||
const id = comparable ? JSON.stringify([
|
||||
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
|
||||
chart.series.filter((series) => series.type !== "scatter").map((series) => [
|
||||
series.name.slice(object.id.length).trim(), series.sample_times ?? null
|
||||
])
|
||||
]) : command.chart_id;
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = { id, title: comparable ? object.title : chart.title, items: [] };
|
||||
groups.set(id, group);
|
||||
}
|
||||
group.items.push({ label: comparable ? object.id : chart.title, command });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
|
||||
import { groupInvestigationCharts } from "./investigation-chart-groups";
|
||||
import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
|
||||
|
||||
|
||||
@@ -47,9 +48,18 @@ describe("switchable investigation Dev cases", () => {
|
||||
expect(nodes[0].props.rows).toHaveLength(50);
|
||||
expect(nodes[1].props.rows).toEqual([]);
|
||||
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
|
||||
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props;
|
||||
expect(pressure.x_data).toHaveLength(96);
|
||||
expect(pressure.series).toHaveLength(8);
|
||||
const commands = charts.commands.filter((command) => command.action === "open_chart");
|
||||
expect(charts.blocks).toHaveLength(0);
|
||||
expect(commands).toHaveLength(13);
|
||||
const groups = groupInvestigationCharts(commands);
|
||||
expect(groups.map((group) => group.items.length)).toEqual([8, 4, 1]);
|
||||
for (const { chart } of commands.slice(0, 12)) {
|
||||
expect(chart.chart_type).toBe("time_comparison");
|
||||
expect(chart.x_data).toHaveLength(96);
|
||||
expect(chart.series).toHaveLength(8);
|
||||
expect(chart.series[0].sample_times).toHaveLength(96);
|
||||
expect(chart.series[1].data.filter((value) => value !== null)).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["data_only", "candidates_table", "valves_table"] as const)(
|
||||
|
||||
@@ -65,6 +65,11 @@ export const investigationDevCases = [
|
||||
id: "invalid_chart",
|
||||
label: "错误:图表数据不齐",
|
||||
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
|
||||
},
|
||||
{
|
||||
id: "invalid_comparison",
|
||||
label: "错误:同期时刻错位",
|
||||
expected: "第 1 行拒绝历史采样时间与横轴槽位不一致的复合图,整批不执行。"
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -258,5 +263,14 @@ export function createInvestigationDevCase(
|
||||
}
|
||||
}
|
||||
]);
|
||||
case "invalid_comparison":
|
||||
return invalidJsonl([{ record_type: "presentation", command: {
|
||||
action: "open_chart", chart_id: "dev-invalid-comparison", evidence_refs: ["dev:synthetic"],
|
||||
chart: { title: "错误案例:历史点时刻错位", chart_type: "time_comparison", x_data: ["08:00", "08:15"],
|
||||
series: [
|
||||
{ name: "当前", type: "line", data: [32, 30], sample_times: ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"] },
|
||||
{ name: "历史", type: "scatter", data: [33, null], sample_times: ["2026-07-15T08:15:00+08:00", null] }
|
||||
] }
|
||||
} }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { AnswerUiBlock } from "@/features/agent";
|
||||
import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview";
|
||||
|
||||
export const complexInvestigationDevCases = [
|
||||
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "3 张图、2 张表与合成覆盖面;可同时放大对比,单位分别展示,不查询真实要素。" },
|
||||
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "压力与管流分别聚合,另有方案柱状图、2 张表与合成覆盖面;右侧切换对象,下方控制图例。" },
|
||||
{ id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" },
|
||||
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "96 个时间点、8 条压力序列,另有流量与方案柱状图;可切换数据表、筛选区间并独立放大。" }
|
||||
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "8 个压力点与 4 个管段各含 96 个时刻、当前曲线与七日历史散点,聚合为 2 个子图表,由总图表窗口统一管理,另有方案柱状图。" }
|
||||
] as const;
|
||||
|
||||
type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"];
|
||||
@@ -19,25 +19,40 @@ const ui = (id: string, node: Node) => createUiRecord({
|
||||
const table = (id: string, title: string, columns: string[], rows: string[][]) =>
|
||||
ui(id, { type: "Table", props: { title, columns, rows }, children: [] });
|
||||
const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
|
||||
const pressure = {
|
||||
title: "合成全天压力对比", chart_type: "line" as const, x_data: times,
|
||||
series: Array.from({ length: 8 }, (_, series) => ({
|
||||
name: `合成节点 ${INVESTIGATION_DEV_NETWORK.nodeAnchors[series].id} · ${series < 4 ? "主干管" : "末端支线"}压力水头(m)`,
|
||||
data: times.map((_, index) => Number((34 + series * 0.6 + Math.sin(index / 7 + series) * 2.4 - (index >= 32 && index < 44 ? 7 - series * 0.4 : 0)).toFixed(2)))
|
||||
}))
|
||||
};
|
||||
const flow = {
|
||||
title: "合成分区流量趋势", chart_type: "line" as const, x_data: times,
|
||||
series: Array.from({ length: 4 }, (_, series) => ({ name: `合成分区 ${series + 1} 流量(m³/h)`,
|
||||
data: times.map((_, index) => Number((120 + series * 28 + Math.sin(index / 9 + series) * 24 + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))) }))
|
||||
};
|
||||
const comparisonChart = (id: string, title: string, axis: string, baseline: number[], current: number[]) => ({
|
||||
title: `${id} ${title}`, chart_type: "time_comparison" as const,
|
||||
x_axis_name: "时间", y_axis_name: axis, x_data: times,
|
||||
series: [{ name: `${id} 当前 2026-07-16`, type: "line" as const, data: current,
|
||||
sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
|
||||
...Array.from({ length: 7 }, (_, day) => {
|
||||
const date = `2026-07-${String(9 + day).padStart(2, "0")}`;
|
||||
return { name: `${id} ${date}`, type: "scatter" as const,
|
||||
data: times.map((_, index) => index >= 40 && index <= 42 ? Number((baseline[index] + day * 0.1).toFixed(2)) : null),
|
||||
sample_times: times.map((time, index) => index >= 40 && index <= 42 ? `${date}T${time}:00+08:00` : null) };
|
||||
})]
|
||||
});
|
||||
const pressure = Array.from({ length: 8 }, (_, object) => {
|
||||
const baseline = times.map((_, index) => Number((34 + object * 0.6 + Math.sin(index / 7 + object) * 2.4).toFixed(2)));
|
||||
return comparisonChart(INVESTIGATION_DEV_NETWORK.nodeAnchors[object].id, "合成全天压力对比", "压力水头(m)", baseline,
|
||||
baseline.map((value, index) => Number((value - (index >= 32 && index < 44 ? 7 - object * 0.4 : 0)).toFixed(2))));
|
||||
});
|
||||
const flow = Array.from({ length: 4 }, (_, object) => {
|
||||
const baseline = times.map((_, index) => Number((120 + object * 28 + Math.sin(index / 9 + object) * 24).toFixed(1)));
|
||||
return comparisonChart(INVESTIGATION_DEV_NETWORK.pipeIds[object], "合成分区流量趋势", "流量(m³/h)", baseline,
|
||||
baseline.map((value, index) => Number((value + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))));
|
||||
});
|
||||
const chart = (id: string, value: ReturnType<typeof comparisonChart>) => createPresentationRecord({
|
||||
action: "open_chart", chart_id: `dev-complex-${id}`, evidence_refs: ["dev:synthetic"], chart: value
|
||||
});
|
||||
const objectCharts = () => [
|
||||
...pressure.map((value, index) => chart(`pressure-${index + 1}`, value)),
|
||||
...flow.map((value, index) => chart(`flow-${index + 1}`, value))
|
||||
];
|
||||
const comparison = {
|
||||
title: "合成候选方案影响对比", chart_type: "bar" as const,
|
||||
x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"],
|
||||
series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }]
|
||||
};
|
||||
const chart = (id: string, value: typeof pressure | typeof flow | typeof comparison) =>
|
||||
ui(id, { type: "Chart", props: { ...value, source: "本地确定性合成数据,仅供展示测试;曲线变化和方案名称均非实际分析结果。" }, children: [] });
|
||||
|
||||
const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"];
|
||||
const rows = Array.from({ length: 50 }, (_, index) => [
|
||||
@@ -53,7 +68,8 @@ const rows = Array.from({ length: 50 }, (_, index) => [
|
||||
|
||||
export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string {
|
||||
if (caseId === "complex_charts") return serializeInvestigationDevJsonl([
|
||||
chart("pressure", pressure), chart("flow", flow), chart("comparison", comparison)
|
||||
...objectCharts(),
|
||||
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison })
|
||||
]);
|
||||
if (caseId === "complex_tables") return serializeInvestigationDevJsonl([
|
||||
table("wide-table", "合成候选核查宽表", columns, rows),
|
||||
@@ -69,7 +85,7 @@ export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longit
|
||||
createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic",
|
||||
data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` },
|
||||
geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }),
|
||||
chart("pressure", pressure), chart("flow", flow),
|
||||
...objectCharts(),
|
||||
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }),
|
||||
table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)),
|
||||
table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"],
|
||||
|
||||
@@ -5,8 +5,22 @@ import {
|
||||
parseInvestigationDevJsonl
|
||||
} from "./investigation-dev-preview";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import chartCases from "../../../../tests/fixtures/chart-publication-cases.json";
|
||||
|
||||
describe("investigation Dev JSONL", () => {
|
||||
it.each(chartCases)("uses publication semantics for command and UI examples: $name", ({ chart, accepted }) => {
|
||||
const records = [
|
||||
{ record_type: "presentation", command: { action: "open_chart", chart_id: "comparison", chart, evidence_refs: ["dev:synthetic"] } },
|
||||
{ record_type: "ui_block", block: { version: "answer-ui@1", block_id: "comparison", fallback_text: "合成测试", spec: {
|
||||
root: "chart", elements: { chart: { type: "Chart", props: { ...chart, source: "合成测试" }, children: [] } }
|
||||
} } }
|
||||
];
|
||||
for (const record of records) {
|
||||
const parse = () => parseInvestigationDevJsonl(JSON.stringify(record));
|
||||
if (accepted) expect(parse).not.toThrow();
|
||||
else expect(parse).toThrow("第 1 行");
|
||||
}
|
||||
});
|
||||
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
|
||||
const result = parseInvestigationDevJsonl(
|
||||
createInvestigationDevSample()
|
||||
@@ -130,7 +144,7 @@ describe("migrated example data", () => {
|
||||
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
|
||||
const { PRESENTATION_ACTIONS } = await import("@/features/agent");
|
||||
const preview = parseInvestigationDevJsonl(input);
|
||||
expect(preview.commands.map((command) => command.action).sort()).toEqual(
|
||||
expect([...new Set(preview.commands.map((command) => command.action))].sort()).toEqual(
|
||||
[...PRESENTATION_ACTIONS].sort()
|
||||
);
|
||||
expect(preview.blocks).toHaveLength(1);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
||||
import {
|
||||
parseAnswerUiBlock,
|
||||
presentationCommandSchema,
|
||||
safeChartPublicationInput,
|
||||
chartPublicationError,
|
||||
type AnswerUiBlock
|
||||
} from "@/features/agent";
|
||||
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
@@ -36,7 +38,11 @@ export const investigationActionLabels: Record<InvestigationPresentationCommand[
|
||||
};
|
||||
|
||||
const references = z.array(z.string().trim().min(1).max(128)).max(100);
|
||||
const command = presentationCommandSchema;
|
||||
const command = presentationCommandSchema.superRefine((value, ctx) => {
|
||||
if (value.action !== "open_chart") return;
|
||||
const chart = safeChartPublicationInput.safeParse(value.chart);
|
||||
if (!chart.success) for (const issue of chart.error.issues) ctx.addIssue({ ...issue, path: ["chart", ...issue.path] });
|
||||
});
|
||||
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
|
||||
const uiRecord = z
|
||||
.object({
|
||||
@@ -78,7 +84,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.record_type === "presentation") {
|
||||
const parsed = presentationRecord.safeParse(value);
|
||||
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`);
|
||||
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法:${parsed.error.issues[0]?.message ?? "请检查动作、参数和范围。"}`);
|
||||
const data = parsed.data;
|
||||
result.commands.push(data.command);
|
||||
} else if (record.record_type === "ui_block") {
|
||||
@@ -86,6 +92,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
|
||||
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
|
||||
if (!block || blockIds.has(block.block_id))
|
||||
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
|
||||
validateChartNodes(block, `第 ${line} 行`);
|
||||
blockIds.add(block.block_id);
|
||||
result.blocks.push(block);
|
||||
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
|
||||
@@ -107,6 +114,7 @@ export function createPresentationRecord(command: InvestigationPresentationComma
|
||||
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
|
||||
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
|
||||
if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
|
||||
validateChartNodes(normalized, "界面块");
|
||||
return uiRecord.parse({
|
||||
record_type: "ui_block",
|
||||
block: normalized,
|
||||
@@ -114,6 +122,15 @@ export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []
|
||||
});
|
||||
}
|
||||
|
||||
function validateChartNodes(block: AnswerUiBlock, prefix: string) {
|
||||
for (const node of Object.values(block.spec.elements)) {
|
||||
if (node.type !== "Chart") continue;
|
||||
// The answer catalog owns its text limits; only publication time semantics are shared.
|
||||
const message = chartPublicationError(node.props as Parameters<typeof chartPublicationError>[0]);
|
||||
if (message) throw new Error(`${prefix}图表不合法:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeInvestigationDevJsonl(records: unknown[]) {
|
||||
const input = records.map((record) => JSON.stringify(record)).join("\n");
|
||||
parseInvestigationDevJsonl(input);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { AnswerUiBlock } from "@/features/agent";
|
||||
import { presentationChartBlocks } from "./presentation-chart-blocks";
|
||||
|
||||
it("collects report charts in document order with distinct identities and intact props", () => {
|
||||
const chart = { type: "Chart", props: { title: "压力", source: "保存的证据", series: [] }, children: [] };
|
||||
const block: AnswerUiBlock = { version: "answer-ui@1", block_id: "report", fallback_text: "报告", spec: {
|
||||
root: "root", elements: {
|
||||
root: { type: "Stack", props: {}, children: ["second", "first", "text"] },
|
||||
first: chart, second: chart, orphan: chart,
|
||||
text: { type: "Text", props: { text: "结论" }, children: [] }
|
||||
}
|
||||
} };
|
||||
const results = presentationChartBlocks([block]);
|
||||
expect(results.map((item) => item.block_id)).toEqual(["report:second", "report:first"]);
|
||||
expect(results[0].spec).toEqual({ root: "second", elements: { second: chart } });
|
||||
expect(results[0].spec.elements.second.props).toBe(chart.props);
|
||||
expect(block.spec.root).toBe("root");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AnswerUiBlock } from "@/features/agent";
|
||||
|
||||
/** Include only reachable Chart nodes, preserving report props and their source. */
|
||||
export function presentationChartBlocks(blocks: AnswerUiBlock[]): AnswerUiBlock[] {
|
||||
return blocks.flatMap((block) => {
|
||||
const charts: AnswerUiBlock[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (id: string) => {
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
const element = block.spec.elements[id];
|
||||
if (!element) return;
|
||||
if (element.type === "Chart") charts.push({
|
||||
...block,
|
||||
block_id: `${block.block_id}:${id}`,
|
||||
spec: { root: id, elements: { [id]: element } }
|
||||
});
|
||||
else element.children?.forEach(visit);
|
||||
};
|
||||
visit(block.spec.root);
|
||||
return charts;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user