diff --git a/docs/investigation-dev-tools.md b/docs/investigation-dev-tools.md
index 4bbe9aa..0abbb16 100644
--- a/docs/investigation-dev-tools.md
+++ b/docs/investigation-dev-tools.md
@@ -139,7 +139,9 @@ Dev 的 presentation 与 ui_block.Chart、新的 Chat open_chart(Agent 主机
## 展示动作历史
-调查实时展示按所选任务读取 Server 已保存的展示动作,各任务的历史在浏览器中分别保留、切换时按任务展示。对话展示和本地预览展示继续保留当前项目、本次页面会话内收到的动作。清除类动作不纳入历史。展示面板的“切换展示动作”以最新优先倒序排列,“当前(最新)”始终置顶,动作编号保留发生顺序。可选择任一已完成、未完成或取消的动作,也可用上一步/下一步逐步回看;进行中的动作显示状态但不可选择。“回到当前”恢复最新展示。 当前结果与最后一条已完成动作相同时,合并显示为“当前(最新)”,下拉和前后导航不再重复这一项。清理后当前结果与最后快照不同,最后一条仍作为历史结果可选。切换任务、切换案例、关闭图表和清除当前展示都会保留历史;切换项目或刷新页面后清空浏览器缓存,重新选择调查任务可再次读取 Server 保存的事件。不设最近若干条的截断。载入和回看不向 Server 发布事件,不重启调查、审批或现场操作;重复选择同一任务按任务编号和事件序号去重。
+调查实时展示按所选任务读取 Server 已保存的展示动作,各任务的历史在浏览器中分别保留、切换时按任务展示。对话展示和本地预览展示继续保留当前项目、本次页面会话内收到的动作。清除类动作不纳入历史。“切换展示动作”始终置顶“当前(最新)”;调查历史按相邻类别组成一级菜单:图表、SCADA 分析、地图操作、覆盖层、报告与数据。点击类别查看该组已完成步骤的合并快照,点击箭头展开二级原始步骤,动作编号保留发生顺序,菜单按最新优先倒序排列。同一类别被其他类别隔开时分别成组;历史轮次相邻同类步骤可在同组查看。桌面使用子菜单,窄屏进入类别后提供“返回类别”。
+
+二级菜单可选择任一已完成、未完成或取消的动作,包括最新原始步骤;进行中的动作显示状态但不可选择。上一步/下一步按一级分组顺序导航,保留失败和取消组,跳过仅进行中的组;历史播放只播放含已完成结果的组。明确选择原始步骤或合并组后固定该快照,新到实时结果不会替换它;“回到当前”恢复最新展示。对话和无调查的本地预览继续使用单层动作列表,相同的当前与最后完成结果不重复列出。切换任务、切换案例、关闭图表和清除当前展示都会保留历史;切换项目或刷新页面后清空浏览器缓存,重新选择调查任务可再次读取 Server 保存的事件。不设最近若干条的截断。载入和回看不向 Server 发布事件,不重启调查、审批或现场操作;重复选择同一任务按任务编号和事件序号去重。
本地快照保存图表、界面块、数据、证据摘要、地图几何及镜头。Server 历史保留原发布指令和空间证据快照;只有要素编号的旧定位指令在首次载入时按当前项目读取可访问几何,不声称这些是历史时刻的资产位置。载入后回看使用本地快照,不重复查询。上一轮结果可作为历史查看,不混入本轮当前状态。单条过期或不合法指令显示失败记录,其他有效结果继续载入。历史导航直接渲染快照,不重新查询资产、执行原指令或发送调查请求。关闭图表、显示/隐藏及定位覆盖层、界面块关联要素定位等操作会产生记录;清除临时展示、清除地图展示,以及 `clear_highlights/clear_overlay/clear_scada_analysis` 指令只更新当前状态,不创建历史条目,已有结果快照仍保留;地图手动拖拽和证据展开属于查看操作,不逐帧记录。记录不是完整底图或外部实时数据的离线副本。
diff --git a/src/features/workbench/components/investigation-presentation-panel.tsx b/src/features/workbench/components/investigation-presentation-panel.tsx
index b41d85c..e3b3d28 100644
--- a/src/features/workbench/components/investigation-presentation-panel.tsx
+++ b/src/features/workbench/components/investigation-presentation-panel.tsx
@@ -93,8 +93,11 @@ export function InvestigationPresentationPanel({
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 selected = history?.mergedEntry?.id === history?.selectedId
+ ? history?.mergedEntry
+ : history?.entries.find((entry) => entry.id === history.selectedId);
+ const selectedLastId = selected?.mergedStepIds?.at(-1) ?? selected?.id;
+ const pending = selectedLastId !== undefined ? history!.entries.filter((entry) => entry.id > selectedLastId).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
const references = (ids: string[]) =>
ids.length ? (
@@ -191,6 +194,9 @@ export function InvestigationPresentationPanel({
: "跟随所选调查,展示地图操作与结果"}
{history?.entries.length ? : null}
+ {selected?.mergedSummary ?
+ 合并内容:{selected.mergedSummary}
+
: null}
{!collapsed ? (
diff --git a/src/features/workbench/components/presentation-history-menu.tsx b/src/features/workbench/components/presentation-history-menu.tsx
new file mode 100644
index 0000000..1d652cd
--- /dev/null
+++ b/src/features/workbench/components/presentation-history-menu.tsx
@@ -0,0 +1,132 @@
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, Radio } from "lucide-react";
+import { Button } from "@/shared/ui/button";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal,
+ DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
+} from "@/shared/ui/dropdown-menu";
+import { groupAdjacentPresentationHistory, presentationHistoryStepLabel, type PresentationHistoryGroup } from "../utils/presentation-history-categories";
+import type { PresentationHistoryControls, PresentationHistoryEntry } from "../utils/presentation-view-history";
+
+const compactQuery = "(max-width: 639px)";
+const compactSnapshot = () => window.matchMedia(compactQuery).matches;
+function subscribeCompact(listener: () => void) {
+ const media = window.matchMedia(compactQuery);
+ media.addEventListener("change", listener);
+ return () => media.removeEventListener("change", listener);
+}
+const rowClass = "min-h-8 gap-2 text-xs active:bg-accent";
+
+export function PresentationHistoryMenu({ entries, selectedId, mergedEntry, mergedEntries, onSelect }: PresentationHistoryControls) {
+ const [open, setOpen] = useState(false);
+ const [expandedGroupId, setExpandedGroupId] = useState(null);
+ const [activeGroupId, setActiveGroupId] = useState(null);
+ const compact = useSyncExternalStore(subscribeCompact, compactSnapshot, () => false);
+ const contentRef = useRef(null);
+ const triggerRef = useRef(null);
+ const choices = mergedEntries ?? (mergedEntry ? [mergedEntry] : []);
+ const groups = groupAdjacentPresentationHistory(entries);
+ const activeGroup = groups.find((group) => group.id === activeGroupId);
+ const selected = mergedEntry?.id === selectedId ? mergedEntry
+ : choices.find((entry) => entry.id === selectedId) ?? entries.find((entry) => entry.id === selectedId);
+ const triggerLabel = selected?.mergedStepIds ? selected.label
+ : selected ? `${entries.indexOf(selected) + 1}. ${presentationHistoryStepLabel(selected)}` : "当前(最新)";
+
+ useEffect(() => {
+ if (!compact || !open) return;
+ const frame = requestAnimationFrame(() => contentRef.current?.querySelector('[role="menuitem"]')?.focus());
+ return () => cancelAnimationFrame(frame);
+ }, [activeGroupId, compact, open]);
+
+ const close = () => { setOpen(false); setActiveGroupId(null); setExpandedGroupId(null); };
+ const select = (id: number | null) => { onSelect(id); close(); };
+ const items = (group: PresentationHistoryGroup) => ;
+
+ return { if (next) setOpen(true); else close(); }}>
+
+
+
+ {
+ // The trigger owns toggling. An exiting menu must not dismiss a fresh
+ // open from the same pointer event while its closing animation finishes.
+ if (event.target instanceof Node && triggerRef.current?.contains(event.target)) event.preventDefault();
+ }}
+ aria-label={compact && activeGroup ? `${activeGroup.label}展示步骤` : "展示类别"} aria-labelledby={undefined}
+ className={`workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)] p-1 ${compact ? "w-72" : "w-40"}`}>
+ {compact && activeGroup ? <>
+ { event.preventDefault(); setActiveGroupId(null); }}>
+ 返回类别
+
+
+ {items(activeGroup)}
+ > : <>
+ select(null)}>
+ 当前(最新)
+ {selectedId === null ? : null}
+
+
+ {groups.slice().reverse().map((group) => {
+ const view = choices.find((entry) => entry.id === group.id) ?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
+ const selectedGroup = selectedId === group.id || group.entries.some((entry) => entry.id === selectedId);
+ const row =
+ {
+ if (!compact && event.key === "ArrowRight") { event.preventDefault(); setExpandedGroupId(group.id); }
+ }} onSelect={() => { if (view) select(view.id); }}>
+ {group.label}{selectedGroup ? : null}
+
+ {compact ? { event.preventDefault(); setActiveGroupId(group.id); }}>
+
+ : svg]:ml-0`} />}
+
;
+ return compact ? {row}
: setExpandedGroupId((previous) => next ? group.id : previous === group.id ? null : previous)}>
+ {row}
+
+ {
+ const target = event.target;
+ const parentMenu = contentRef.current;
+ // Radix focuses the parent menu between pointer targets. Both actions
+ // in this split row still belong to the same expanded category.
+ if (target instanceof Node && (target === parentMenu ||
+ parentMenu?.querySelector(`[data-history-group-id="${group.id}"]`)?.contains(target)))
+ event.preventDefault();
+ }}
+ className="workbench-shadcn-surface z-[91] max-h-(--radix-dropdown-menu-content-available-height) w-80 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain">
+ {items(group)}
+
+
+ ;
+ })}
+ >}
+
+ ;
+}
+
+function GroupHistoryItems({ group, entries, selectedId, onSelect }: {
+ group: PresentationHistoryGroup;
+ entries: PresentationHistoryEntry[];
+ selectedId: number | null;
+ onSelect: (id: number) => void;
+}) {
+ return group.entries.slice().reverse().map((entry) => onSelect(entry.id)}>
+ {entries.indexOf(entry) + 1}. {presentationHistoryStepLabel(entry)}
+ {entry.status === "running" ?
+ : entry.status !== "completed" ?
+ : entry.id === selectedId ? : null}
+ );
+}
diff --git a/src/features/workbench/components/presentation-history-switcher.test.tsx b/src/features/workbench/components/presentation-history-switcher.test.tsx
index c5da741..53ab390 100644
--- a/src/features/workbench/components/presentation-history-switcher.test.tsx
+++ b/src/features/workbench/components/presentation-history-switcher.test.tsx
@@ -1,11 +1,60 @@
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import "@/test/radix-select-test-setup";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
+import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
-afterEach(cleanup);
+beforeEach(() => {
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
+});
+afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+it.each(["error", "cancelled"] as const)("keeps navigation ordered when a %s step is selected", async (status) => {
+ const history = new PresentationViewHistory();
+ const metadata = { taskId: "task", executionGeneration: 1 };
+ history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts" });
+ const failed = history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
+ history.finish(failed, status);
+ history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts" });
+ const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render();
+ expect(screen.getByRole("button", { name: "上一个动作" })).toBeEnabled();
+ await user.click(screen.getByRole("button", { name: "上一个动作" }));
+ expect(onSelect).toHaveBeenLastCalledWith(groups[0].id);
+ await user.click(screen.getByRole("button", { name: "下一个动作" }));
+ expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
+});
+
+it("selects merged adjacent steps from their concise first-level title and lists only original steps below", async () => {
+ const history = new PresentationViewHistory();
+ const metadata = { taskId: "task", executionGeneration: 1 };
+ history.record("打开图表 · 压力曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
+ history.record("打开图表 · 流量曲线", "live", "SCADA 异常调查", { ...metadata, category: "charts" });
+ history.record("SCADA 分析", "live", "SCADA 异常调查", { ...metadata, category: "scada" });
+ const mergedEntries = mergeAdjacentPresentationHistory(history.entries, "task", "SCADA 异常调查");
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByRole("button", { name: "切换展示动作" }));
+ const menu = screen.getByRole("menu", { name: "展示类别" });
+ expect([...menu.querySelectorAll("[data-history-group-row]")].map((item) => item.textContent)).toEqual(["SCADA 分析", "图表"]);
+ expect(screen.queryByText("合并查看")).not.toBeInTheDocument();
+ await user.click(within(menu).getByRole("menuitem", { name: "图表" }));
+ expect(onSelect).toHaveBeenLastCalledWith(mergedEntries[0].id);
+ await user.click(screen.getByRole("button", { name: "切换展示动作" }));
+ await user.click(screen.getByRole("menuitem", { name: "展开图表步骤" }));
+ const charts = screen.getByRole("menu", { name: "图表展示步骤" });
+ expect(within(charts).getAllByRole("menuitem").map((item) => item.textContent)).toEqual(["2. 流量曲线", "1. 压力曲线"]);
+ within(charts).getByRole("menuitem", { name: "1. 压力曲线" }).focus();
+ await user.keyboard("{Enter}");
+ expect(onSelect).toHaveBeenLastCalledWith(history.entries[0].id);
+});
function entries() {
const history = new PresentationViewHistory();
@@ -79,3 +128,39 @@ it("offers playback, speed, progress and keeps the latest snapshot pinned until
await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null);
});
+
+it("counts and navigates first-level groups, mapping an original step to its parent position", async () => {
+ const user = userEvent.setup();
+ const history = new PresentationViewHistory();
+ const metadata = { taskId: "task", executionGeneration: 1 };
+ history.record("chart one", "live", "调查", { ...metadata, category: "charts" });
+ const child = history.record("chart two", "live", "调查", { ...metadata, category: "charts" });
+ history.record("map", "live", "调查", { ...metadata, category: "map" });
+ history.record("chart three", "live", "调查", { ...metadata, category: "charts" });
+ const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
+ const onSelect = vi.fn();
+ const playback = { entries: groups, playing: false, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
+ const props = { entries: history.entries, currentEntryId: history.currentEntryId,
+ mergedEntries: groups, onSelect, playback };
+ const { rerender } = render();
+ const slider = screen.getByRole("slider", { name: "历史播放进度" });
+ expect(slider).toHaveAttribute("max", "2");
+ expect(slider).toHaveValue("0");
+ expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
+ expect(screen.getByRole("button", { name: "上一个动作" })).toBeDisabled();
+ await user.click(screen.getByRole("button", { name: "下一个动作" }));
+ expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
+ fireEvent.change(slider, { target: { value: "1" } });
+ expect(playback.onSeek).toHaveBeenLastCalledWith(groups[1].id);
+ rerender();
+ expect(slider).toHaveValue("0");
+ expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("1/3");
+ await user.click(screen.getByRole("button", { name: "下一个动作" }));
+ expect(onSelect).toHaveBeenLastCalledWith(groups[1].id);
+ rerender();
+ expect(slider).toHaveValue("1");
+ expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/3");
+ rerender();
+ expect(slider).toHaveValue("2");
+ expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("3/3");
+});
diff --git a/src/features/workbench/components/presentation-history-switcher.tsx b/src/features/workbench/components/presentation-history-switcher.tsx
index 6e545eb..aaed0fc 100644
--- a/src/features/workbench/components/presentation-history-switcher.tsx
+++ b/src/features/workbench/components/presentation-history-switcher.tsx
@@ -2,6 +2,9 @@ import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
+import { groupAdjacentPresentationHistory } from "../utils/presentation-history-categories";
+
+import { PresentationHistoryMenu } from "./presentation-history-menu";
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
@@ -9,19 +12,28 @@ export function PresentationHistorySwitcher({
entries,
currentEntryId,
selectedId,
+ mergedEntry,
+ mergedEntries,
onSelect,
playback
}: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
- const available = entries.filter(
+ const playable = playback?.entries ?? (mergedEntries?.length ? mergedEntries : entries).filter((entry) => entry.status === "completed");
+ const grouped = entries.some((entry) => entry.taskId);
+ const available = grouped ? groupAdjacentPresentationHistory(entries).flatMap((group) => {
+ const view = mergedEntries?.find((entry) => entry.id === group.id)
+ ?? group.entries.slice().reverse().find((entry) => entry.status !== "running");
+ return view ? [{ id: view.id, stepIds: group.entries.map((entry) => entry.id) }] : [];
+ }) : entries.filter(
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
- );
+ ).map((entry) => ({ id: entry.id, stepIds: [entry.id] }));
const index =
selectedId === null
? available.length
- : available.findIndex((entry) => entry.id === selectedId);
- const playable = entries.filter((entry) => entry.status === "completed");
- const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
+ : available.findIndex((entry) => entry.id === selectedId ||
+ entry.stepIds.includes(selectedId));
+ const progress = selectedId === null ? Math.max(0, playable.length - 1)
+ : Math.max(0, playable.findIndex((entry) => entry.id === selectedId || entry.mergedStepIds?.includes(selectedId)));
return (
@@ -36,7 +48,10 @@ export function PresentationHistorySwitcher({
>
-
}
:
}
{ const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
- {selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
+ {playable.length ? progress + 1 : 0}/{playable.length}
playback.onSpeedChange(Number(value))}>
{ cleanup(); vi.useRealTimers(); });
+it("pins an explicitly selected latest step until returning to current", async () => {
+ const { result } = renderHook(() => useInvestigationPresentation(options));
+ await act(() => result.current.runPreview({ blocks: [], records: [{ value: "selected" }], commands: [] }));
+ const latestId = result.current.history.entries.at(-1)!.id;
+ act(() => result.current.history.onSelect(latestId));
+ expect(result.current.history.selectedId).toBe(latestId);
+ await act(() => result.current.runPreview({ blocks: [], records: [{ value: "new current" }], commands: [] }));
+ expect(result.current.records).toEqual([{ value: "selected" }]);
+ act(() => result.current.history.onSelect(null));
+ expect(result.current.records).toEqual([{ value: "new current" }]);
+});
+
it("never commits a previous task's display under the newly selected task scope", async () => {
const commits: Array<{ scope: string; records: unknown[] }> = [];
const { result, rerender } = renderHook(({ scopeId }) => {
diff --git a/src/features/workbench/hooks/use-investigation-presentation.ts b/src/features/workbench/hooks/use-investigation-presentation.ts
index 579db7b..421cd91 100644
--- a/src/features/workbench/hooks/use-investigation-presentation.ts
+++ b/src/features/workbench/hooks/use-investigation-presentation.ts
@@ -16,6 +16,8 @@ import {
} from "../api/investigation-presentation";
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
import { presentationCommandLabel } from "../utils/presentation-command-label";
+import { mergeAdjacentPresentationHistory } from "../utils/merge-presentation-history";
+import { presentationCommandCategory } from "../utils/presentation-history-categories";
import type {
InvestigationPresentationCommand
} from "../api/investigation-presentation";
@@ -187,12 +189,28 @@ export function useInvestigationPresentation(options: Options) {
const record = useCallback(
(label: string, origin = source()) => {
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
- taskId: valuesRef.current.task?.task_id
+ taskId: valuesRef.current.task?.task_id,
+ executionGeneration: valuesRef.current.task?.execution_generation
});
publish();
},
[publish, source]
);
+ const historyEntries = storeRef.current.entries;
+ const taskEntries =
+ !options.scope || options.scope === "investigation"
+ ? historyEntries.filter((entry) => entry.taskId === taskId)
+ : historyEntries;
+ const mergedEntries = useMemo(() => {
+ if ((options.scope && options.scope !== "investigation") || !options.task) return [];
+ return mergeAdjacentPresentationHistory(historyEntries, options.task.task_id, options.task.title);
+ }, [options.scope, options.task, historyEntries]);
+ const selectedMerged = storeRef.current.selected?.mergedStepIds ? storeRef.current.selected : null;
+ const mergedHistory = mergedEntries.map((entry) => selectedMerged && entry.id === selectedMerged.id ? selectedMerged : entry);
+ // Keep both group order and composed snapshots fixed until the user leaves playback.
+ const playbackEntries = storeRef.current.playbackEntries.length
+ ? storeRef.current.playbackEntries
+ : (mergedHistory.length ? mergedHistory : taskEntries).filter((entry) => entry.status === "completed");
const selectHistory = useCallback(
(id: number | null, pin = false) => {
const store = storeRef.current;
@@ -202,12 +220,18 @@ export function useInvestigationPresentation(options: Options) {
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
- if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
+ if (pin && id !== null) {
+ if (!store.seekPlayback(id, playbackEntries)) return;
+ } else if (id !== null && id < 0) {
+ const merged = mergedHistory.find((entry) => entry.id === id);
+ if (!merged) return;
+ store.selectMerged(merged);
+ } else if (!store.select(id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
- [publish, scene]
+ [publish, scene, mergedHistory, playbackEntries]
);
const clearMapOverlays = useCallback(
@@ -274,7 +298,10 @@ export function useInvestigationPresentation(options: Options) {
presentationCommandLabel(command, store.current),
origin,
valuesRef.current.task?.title ?? null,
- { key: eventKey, taskId: valuesRef.current.task?.task_id }
+ { key: eventKey, taskId: valuesRef.current.task?.task_id,
+ executionGeneration: event?.execution_generation ?? valuesRef.current.task?.execution_generation,
+ category: presentationCommandCategory(command.action),
+ chartId: command.action === "open_chart" || command.action === "close_chart" ? command.chart_id : undefined }
);
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current;
@@ -561,10 +588,6 @@ export function useInvestigationPresentation(options: Options) {
return createInvestigationDevCase(caseId);
}, []);
- const taskEntries =
- !options.scope || options.scope === "investigation"
- ? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
- : storeRef.current.entries;
const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => {
@@ -598,9 +621,12 @@ export function useInvestigationPresentation(options: Options) {
history: {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
+ mergedEntry: selectedMerged ?? mergedEntries.at(-1) ?? null,
+ mergedEntries: mergedHistory,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory,
playback: {
+ entries: playbackEntries,
playing,
speed: playbackSpeed,
onToggle: () => {
@@ -609,7 +635,7 @@ export function useInvestigationPresentation(options: Options) {
else {
if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
- store.startPlayback(taskEntries);
+ store.startPlayback(playbackEntries);
}
publish();
const map = valuesRef.current.mapRef.current;
diff --git a/src/features/workbench/utils/investigation-presentation-archive.ts b/src/features/workbench/utils/investigation-presentation-archive.ts
index 6a0cd79..afa2abc 100644
--- a/src/features/workbench/utils/investigation-presentation-archive.ts
+++ b/src/features/workbench/utils/investigation-presentation-archive.ts
@@ -10,6 +10,7 @@ import {
type PresentationPorts
} from "../map/presentation-command-executor";
import { presentationCommandLabel } from "./presentation-command-label";
+import { presentationCommandCategory } from "./presentation-history-categories";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
/** Hydrate saved display data offscreen. No business actions, SSE publication or map mutations. */
@@ -50,6 +51,9 @@ export async function hydrateInvestigationPresentationArchive({
execution_generation: event.execution_generation,
command: event.command
});
+ const label = parsed.success
+ ? presentationCommandLabel(parsed.data.command, staging.current)
+ : "历史展示参数无效";
let message: string | undefined;
try {
if (!parsed.success) throw new Error("已保存的展示指令不符合当前格式,未执行。");
@@ -73,12 +77,13 @@ export async function hydrateInvestigationPresentationArchive({
signal.throwIfAborted();
if (parsed.success && parsed.data.command.action.startsWith("clear_")) continue;
const id = history.begin(
- parsed.success
- ? presentationCommandLabel(parsed.data.command, staging.current)
- : "历史展示参数无效",
+ label,
"live",
`${task.title} · 第 ${generation} 轮`,
- { key, taskId: task.task_id, createdAt: Date.parse(event.occurred_at) }
+ { key, taskId: task.task_id, executionGeneration: event.execution_generation, createdAt: Date.parse(event.occurred_at),
+ category: parsed.success ? presentationCommandCategory(parsed.data.command.action) : "results",
+ chartId: parsed.success && (parsed.data.command.action === "open_chart" || parsed.data.command.action === "close_chart")
+ ? parsed.data.command.chart_id : undefined }
);
history.finish(id, message ? "error" : "completed", message, staging.current);
}
@@ -99,6 +104,8 @@ export async function hydrateInvestigationPresentationArchive({
const id = history.begin(`报告第 ${report.revision} 版 · 数据与分析`, "live", task.title, {
key,
taskId: task.task_id,
+ executionGeneration: report.execution_generation,
+ category: "results",
createdAt: Date.parse(report.published_at)
});
history.finish(id, "completed", undefined, current);
diff --git a/src/features/workbench/utils/merge-presentation-history.test.ts b/src/features/workbench/utils/merge-presentation-history.test.ts
new file mode 100644
index 0000000..2415459
--- /dev/null
+++ b/src/features/workbench/utils/merge-presentation-history.test.ts
@@ -0,0 +1,139 @@
+import { expect, it } from "vitest";
+import { mergePresentationHistory, mergeAdjacentPresentationHistory } from "./merge-presentation-history";
+import { groupAdjacentPresentationHistory } from "./presentation-history-categories";
+import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
+
+const metadata = { taskId: "task-a", executionGeneration: 2 };
+const chart = (id: string, value = 20) => ({ action: "open_chart" as const, chart_id: id, evidence_refs: ["reading"],
+ chart: { title: id, chart_type: "line" as const, x_data: ["08:00"], series: [{ name: "压力", data: [value] }] } });
+const point = (device_id: string) => ({ type: "Feature" as const, properties: { device_id },
+ geometry: { type: "Point" as const, coordinates: [121.4, 31.1] } });
+
+it("merges consecutive categories in order and prevents earlier charts leaking into a later group", () => {
+ const history = new PresentationViewHistory();
+ history.update({ ...history.current, charts: [chart("A")] });
+ history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
+ history.update({ ...history.current, charts: [chart("A"), chart("B")] });
+ history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
+ history.update({ ...history.current, scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
+ history.record("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
+ history.update({ ...history.current, charts: [chart("A"), chart("B"), chart("C")] });
+ history.record("打开图表 · C", "live", "调查", { ...metadata, category: "charts", chartId: "C" });
+ const groups = groupAdjacentPresentationHistory(history.entries);
+ expect(groups.map((group) => [group.label, group.entries.length])).toEqual([["图表", 2], ["SCADA 分析", 1], ["图表", 1]]);
+ const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
+ expect(merged.map((entry) => entry.id)).toEqual([-1, -3, -4]);
+ expect(merged.map((entry) => entry.snapshot.charts.map((item) => item.chart_id))).toEqual([["A", "B"], [], ["C"]]);
+ expect(merged[0].snapshot.scada).toBeNull();
+ expect(merged[1].snapshot.scada?.items).toHaveLength(1);
+ expect(merged[2].snapshot.scada).toBeNull();
+});
+
+it("groups the selected task's sequence while keeping preview and live results separate", () => {
+ const history = new PresentationViewHistory();
+ const record = (extra: Partial = {}, source: "live" | "preview" = "live") =>
+ history.record("打开图表 · A", source, "调查", { ...metadata, category: "charts", chartId: "A", ...extra });
+ history.update({ ...history.current, charts: [chart("A")] });
+ record();
+ record({ taskId: "task-b" });
+ record();
+ record({}, "preview");
+ record({ executionGeneration: 3 });
+ const merged = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查");
+ expect(groupAdjacentPresentationHistory(history.entries)).toHaveLength(5);
+ expect(merged.map((entry) => entry.mergedStepIds)).toEqual([[1, 3], [5]]);
+});
+
+it("merges adjacent categories across historical rounds while preserving each original step", () => {
+ const history = new PresentationViewHistory();
+ for (const generation of [1, 7]) {
+ history.update({ ...emptyPresentationView(), charts: [chart(`G${generation}`)] });
+ history.record(`打开图表 · G${generation}`, "live", "调查", { ...metadata,
+ executionGeneration: generation, category: "charts", chartId: `G${generation}` });
+ }
+ const groups = groupAdjacentPresentationHistory(history.entries);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].entries.map((entry) => entry.executionGeneration)).toEqual([1, 7]);
+ expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0].snapshot.charts.map((item) => item.chart_id)).toEqual(["G1", "G7"]);
+});
+
+it("does not discard pending or failed intervening categories before grouping", () => {
+ const history = new PresentationViewHistory();
+ history.update({ ...history.current, charts: [chart("A")] });
+ history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
+ const failed = history.begin("SCADA 分析", "live", "调查", { ...metadata, category: "scada" });
+ history.finish(failed, "error", "not found");
+ history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
+ history.begin("地图定位", "live", "调查", { ...metadata, category: "map" });
+ expect(groupAdjacentPresentationHistory(history.entries).map((group) => group.label)).toEqual(["图表", "SCADA 分析", "图表", "地图操作"]);
+ expect(mergeAdjacentPresentationHistory(history.entries, "task-a", "调查").map((entry) => entry.mergedStepIds)).toEqual([[1], [3]]);
+});
+
+it("keeps a selected first-level group frozen while its adjacent live run grows", () => {
+ const history = new PresentationViewHistory();
+ history.update({ ...history.current, charts: [chart("A")] });
+ history.record("打开图表 · A", "live", "调查", { ...metadata, category: "charts", chartId: "A" });
+ const selected = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
+ history.selectMerged(selected);
+ history.update({ ...history.current, charts: [chart("A"), chart("B")] });
+ history.record("打开图表 · B", "live", "调查", { ...metadata, category: "charts", chartId: "B" });
+ const next = mergeAdjacentPresentationHistory(history.entries, "task-a", "调查")[0];
+ expect(next.id).toBe(selected.id);
+ expect(next.mergedStepIds).toEqual([1, 2]);
+ expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
+ history.select(null);
+ expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
+});
+
+it("combines saved chart and SCADA results with the latest value per object and isolates the task and generation", () => {
+ const history = new PresentationViewHistory();
+ history.update({ ...emptyPresentationView(), charts: [chart("old-task")] });
+ history.record("other task", "live", "其他调查", { ...metadata, taskId: "task-b" });
+ history.update({ ...emptyPresentationView(), charts: [chart("old-round")] });
+ history.record("old round", "live", "调查", { ...metadata, executionGeneration: 1 });
+ history.update({ ...emptyPresentationView(), charts: [chart("A")],
+ highlights: { type: "FeatureCollection", features: [point("P1")] }, highlightLayers: ["scada"],
+ scada: { data: { type: "FeatureCollection", features: [point("P1")] }, items: [{ device_id: "P1", level: "high" }] } });
+ const first = history.record("first", "live", "调查", metadata);
+ history.update({ ...emptyPresentationView(), charts: [chart("A", 25), chart("B")],
+ highlights: { type: "FeatureCollection", features: [point("P2")] }, highlightLayers: ["pipes"],
+ scada: { data: { type: "FeatureCollection", features: [point("P2")] },
+ items: [{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }] } });
+ const second = history.record("second", "live", "调查", metadata);
+ // Closing a chart removes it from current but leaves it available in the combined result.
+ history.update(emptyPresentationView());
+ history.record("close", "live", "调查", metadata);
+ history.update({ ...emptyPresentationView(), charts: [chart("failed")] });
+ history.finish(history.begin("failed", "live", "调查", metadata), "error");
+ history.record("preview", "preview", "调查", metadata);
+ const before = structuredClone(history.entries);
+ const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
+ expect(merged.mergedStepIds).toEqual([first, second, second + 1]);
+ expect(merged.snapshot.charts.map((item) => [item.chart_id, item.chart.series[0].data[0]])).toEqual([["A", 25], ["B", 20]]);
+ expect(merged.snapshot.scada?.items).toEqual([{ device_id: "P1", level: "low" }, { device_id: "P2", level: "medium" }]);
+ expect(merged.snapshot.highlights.features).toHaveLength(2);
+ expect(merged.snapshot.scada?.data.features).toHaveLength(2);
+ expect(merged.snapshot.highlightLayers).toEqual(["scada", "pipes"]);
+ expect(merged.mergedSummary).toBe("图表 2 张 · SCADA 标记 2 个 · 地图高亮 2 个");
+ expect(history.entries).toEqual(before);
+});
+
+it("pins the combined snapshot while live results arrive and preserves single-step playback", () => {
+ const history = new PresentationViewHistory();
+ history.update({ ...emptyPresentationView(), charts: [chart("A")] });
+ history.record("first", "live", "调查", metadata);
+ expect(mergePresentationHistory(history.entries, "task-a", 2, "调查")).toBeNull();
+ history.update({ ...emptyPresentationView(), charts: [chart("B")] });
+ history.record("second", "live", "调查", metadata);
+ const merged = mergePresentationHistory(history.entries, "task-a", 2, "调查")!;
+ history.selectMerged(merged);
+ history.update({ ...emptyPresentationView(), charts: [chart("C")] });
+ history.record("third", "live", "调查", metadata);
+ expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A", "B"]);
+ expect(history.selected?.mergedStepIds).toHaveLength(2);
+ history.startPlayback(history.entries);
+ expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["A"]);
+ history.select(null);
+ expect(history.visible.charts.map((item) => item.chart_id)).toEqual(["C"]);
+ expect(history.entries).toHaveLength(3);
+});
diff --git a/src/features/workbench/utils/merge-presentation-history.ts b/src/features/workbench/utils/merge-presentation-history.ts
new file mode 100644
index 0000000..c96f549
--- /dev/null
+++ b/src/features/workbench/utils/merge-presentation-history.ts
@@ -0,0 +1,109 @@
+import type { FeatureCollection } from "geojson";
+import { emptyPresentationView, type PresentationHistoryEntry, type PresentationHistoryCategory } from "./presentation-view-history";
+import { groupAdjacentPresentationHistory, presentationHistoryCategories, presentationHistoryCategory, presentationHistoryStepLabel } from "./presentation-history-categories";
+
+function mergeFeatures(collections: FeatureCollection[]): FeatureCollection {
+ const features = new Map();
+ for (const collection of collections) {
+ for (const feature of collection.features) {
+ // Geometry distinguishes equal numeric IDs from different model layers.
+ const key = JSON.stringify([feature.geometry, feature.id ?? null, feature.properties?.device_id ?? null]);
+ features.set(key, feature);
+ }
+ }
+ return { type: "FeatureCollection", features: [...features.values()] };
+}
+
+/** Compose captured results only. Never replay commands or mutate their saved snapshots. */
+export function mergePresentationHistory(
+ entries: PresentationHistoryEntry[],
+ taskId: string,
+ executionGeneration: number,
+ taskTitle: string,
+ category?: PresentationHistoryCategory
+): PresentationHistoryEntry | null {
+ const completed = entries.filter((entry) => entry.taskId === taskId &&
+ entry.executionGeneration === executionGeneration && entry.source === "live" && entry.status === "completed" &&
+ (!category || presentationHistoryCategory(entry) === category));
+ if (completed.length < 2) return null;
+ return mergeCompletedPresentations(completed, taskId, executionGeneration, taskTitle, category);
+}
+
+function mergeCompletedPresentations(
+ completed: PresentationHistoryEntry[], taskId: string, executionGeneration: number,
+ taskTitle: string, category?: PresentationHistoryCategory
+): PresentationHistoryEntry {
+ const snapshot = emptyPresentationView();
+ const charts = new Map();
+ const evidence = new Map();
+ const scada = new Map["items"][number]>();
+ for (const { snapshot: view } of completed) {
+ for (const chart of view.charts) charts.set(chart.chart_id, chart);
+ for (const item of view.evidence) evidence.set(item.evidence_id, item);
+ for (const item of view.scada?.items ?? []) scada.set(item.device_id, item);
+ if (view.blocks.length) snapshot.blocks = view.blocks;
+ if (view.records.length) snapshot.records = view.records;
+ if (view.overlay) snapshot.overlay = view.overlay;
+ if (view.camera) snapshot.camera = view.camera;
+ }
+ snapshot.charts = [...charts.values()];
+ snapshot.evidence = [...evidence.values()];
+ snapshot.highlights = mergeFeatures(completed.map((entry) => entry.snapshot.highlights));
+ snapshot.highlightLayers = [...new Set(completed.flatMap((entry) => entry.snapshot.highlightLayers))];
+ if (scada.size) snapshot.scada = {
+ data: mergeFeatures(completed.flatMap((entry) => entry.snapshot.scada ? [entry.snapshot.scada.data] : [])),
+ items: [...scada.values()]
+ };
+ if (category) {
+ if (category !== "charts") snapshot.charts = [];
+ if (category !== "scada") snapshot.scada = null;
+ if (category !== "map") { snapshot.highlights = { type: "FeatureCollection", features: [] }; snapshot.highlightLayers = []; }
+ if (category !== "overlay") snapshot.overlay = null;
+ if (category !== "results") { snapshot.blocks = []; snapshot.records = []; }
+ if (category === "charts" || category === "results") snapshot.camera = null;
+ }
+ const categoryIndex = presentationHistoryCategories.findIndex((item) => item.id === category);
+ const categoryLabel = presentationHistoryCategories[categoryIndex]?.label;
+ return {
+ id: category ? -(executionGeneration * 10 + categoryIndex + 1) : -executionGeneration,
+ taskId,
+ executionGeneration,
+ taskTitle,
+ category,
+ source: "live",
+ label: `${categoryLabel ? `${categoryLabel}合并` : "合并展示"} · 第 ${executionGeneration} 轮 · ${completed.length} 个步骤`,
+ status: "completed",
+ createdAt: completed.at(-1)!.createdAt,
+ mergedStepIds: completed.map((entry) => entry.id),
+ mergedSummary: [
+ snapshot.charts.length ? `图表 ${snapshot.charts.length} 张` : null,
+ snapshot.scada?.items.length ? `SCADA 标记 ${snapshot.scada.items.length} 个` : null,
+ snapshot.highlights.features.length ? `地图高亮 ${snapshot.highlights.features.length} 个` : null,
+ snapshot.overlay ? "覆盖层 1 个" : null,
+ snapshot.records.length ? `数据记录 ${snapshot.records.length} 条` : null,
+ snapshot.blocks.length ? `报告内容 ${snapshot.blocks.length} 项` : null,
+ snapshot.camera ? "地图视角" : null
+ ].filter(Boolean).join(" · ") || `展示步骤 ${completed.length} 项`,
+ snapshot
+ };
+}
+
+export function mergeAdjacentPresentationHistory(
+ entries: PresentationHistoryEntry[], taskId: string, taskTitle: string
+): PresentationHistoryEntry[] {
+ return groupAdjacentPresentationHistory(entries.filter((entry) => entry.taskId === taskId)).flatMap((group) => {
+ const first = group.entries[0];
+ if (first.taskId !== taskId || first.executionGeneration === undefined) return [];
+ const scoped = group.category === "charts" ? group.entries.map((entry) => {
+ const named = entry.snapshot.charts.filter((chart) => entry.chartId !== undefined
+ ? chart.chart_id === entry.chartId : chart.chart.title === presentationHistoryStepLabel(entry));
+ return { ...entry, snapshot: { ...entry.snapshot,
+ charts: entry.chartId !== undefined || named.length ? named : entry.snapshot.charts } };
+ }) : group.entries;
+ const completed = scoped.filter((entry) => entry.source === "live" && entry.status === "completed");
+ if (!completed.length) return [];
+ const generation = completed.at(-1)!.executionGeneration ?? first.executionGeneration;
+ const merged = mergeCompletedPresentations(completed, taskId, generation, taskTitle, group.category);
+ return [{ ...merged, id: group.id, label: group.label }];
+ });
+}
diff --git a/src/features/workbench/utils/presentation-history-categories.ts b/src/features/workbench/utils/presentation-history-categories.ts
new file mode 100644
index 0000000..942984c
--- /dev/null
+++ b/src/features/workbench/utils/presentation-history-categories.ts
@@ -0,0 +1,67 @@
+import type { InvestigationPresentationCommand } from "../api/investigation-presentation";
+import type { PresentationHistoryCategory, PresentationHistoryEntry } from "./presentation-view-history";
+
+export const presentationHistoryCategories: Array<{ id: PresentationHistoryCategory; label: string }> = [
+ { id: "charts", label: "图表" },
+ { id: "scada", label: "SCADA 分析" },
+ { id: "map", label: "地图操作" },
+ { id: "overlay", label: "覆盖层" },
+ { id: "results", label: "报告与数据" }
+];
+
+export type PresentationHistoryGroup = {
+ id: number;
+ category: PresentationHistoryCategory;
+ label: string;
+ entries: PresentationHistoryEntry[];
+};
+
+/** Group before excluding failed or pending results so intervening categories retain their position. */
+export function groupAdjacentPresentationHistory(entries: PresentationHistoryEntry[]): PresentationHistoryGroup[] {
+ const groups: PresentationHistoryGroup[] = [];
+ for (const entry of entries) {
+ const category = presentationHistoryCategory(entry);
+ const previousGroup = groups.at(-1);
+ const previous = previousGroup?.entries.at(-1);
+ if (previous && previousGroup?.category === category && previous.taskId === entry.taskId &&
+ previous.source === entry.source) {
+ previousGroup.entries.push(entry);
+ } else {
+ groups.push({ id: -entry.id, category,
+ label: presentationHistoryCategories.find((item) => item.id === category)!.label, entries: [entry] });
+ }
+ }
+ return groups;
+}
+
+export function presentationCommandCategory(action: InvestigationPresentationCommand["action"]): PresentationHistoryCategory {
+ switch (action) {
+ case "open_chart": case "close_chart": return "charts";
+ case "render_scada_analysis": case "clear_scada_analysis": return "scada";
+ case "set_overlay": case "clear_overlay": return "overlay";
+ default: return "map";
+ }
+}
+
+/** Label fallback also classifies page-local snapshots created before this update. */
+export function presentationHistoryCategory(entry: PresentationHistoryEntry): PresentationHistoryCategory {
+ if (entry.category) return entry.category;
+ if (/^(打开|关闭)图表/.test(entry.label)) return "charts";
+ if (/^(清除 )?SCADA\s*分析/.test(entry.label)) return "scada";
+ if (/^(显示|隐藏|清除|定位)覆盖层/.test(entry.label)) return "overlay";
+ if (/^地图|^调整视角|^清除高亮|^定位关联要素/.test(entry.label)) return "map";
+ return "results";
+}
+
+export function presentationHistoryStepLabel(entry: PresentationHistoryEntry): string {
+ const separator = entry.label.indexOf(" · ");
+ if (separator >= 0) {
+ const action = entry.label.slice(0, separator);
+ const detail = entry.label.slice(separator + 3);
+ if (action === "打开图表" || action === "显示覆盖层" || action === "展示结果") return detail;
+ if (action === "关闭图表") return `关闭 · ${detail}`;
+ if (action === "地图定位") return `定位 · ${detail}`;
+ if (action === "地图高亮") return `高亮 · ${detail}`;
+ }
+ return entry.label === "SCADA 分析" ? "分析结果" : entry.label;
+}
diff --git a/src/features/workbench/utils/presentation-view-history.test.ts b/src/features/workbench/utils/presentation-view-history.test.ts
index b971681..09e9204 100644
--- a/src/features/workbench/utils/presentation-view-history.test.ts
+++ b/src/features/workbench/utils/presentation-view-history.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { emptyPresentationView, PresentationViewHistory } from "./presentation-view-history";
+import { mergeAdjacentPresentationHistory } from "./merge-presentation-history";
describe("presentation history", () => {
it("updates a retried event in place and ignores completion from its superseded attempt", () => {
@@ -121,3 +122,54 @@ it("pauses playback on manual seeking and scope reset", () => {
expect(history.playing).toBe(false);
expect(history.playbackIds).toEqual([]);
});
+
+it("plays and seeks frozen adjacent groups, retaining their merged snapshots across live arrivals and pause", () => {
+ const history = new PresentationViewHistory();
+ const metadata = { taskId: "task", executionGeneration: 1 };
+ history.record("first", "live", "调查", { ...metadata, category: "charts" });
+ history.record("second", "live", "调查", { ...metadata, category: "charts" });
+ history.record("map", "live", "调查", { ...metadata, category: "map" });
+ const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
+
+ expect(history.startPlayback(groups)).toBe(true);
+ expect(history.selectedId).toBe(-1);
+ expect(history.selected).toBe(groups[0]);
+ expect(history.visible).toBe(groups[0].snapshot);
+ history.record("later map", "live", "调查", { ...metadata, category: "map" });
+ history.record("later chart", "live", "调查", { ...metadata, category: "charts" });
+ expect(history.playbackIds).toEqual(groups.map((entry) => entry.id));
+ expect(history.advancePlayback()).toBe(true);
+ expect(history.visible).toBe(groups[1].snapshot);
+ expect(history.selected?.mergedStepIds).toEqual([3]);
+ expect(history.advancePlayback()).toBe(false);
+ expect(history.seekPlayback(groups[0].id)).toBe(true);
+ expect(history.playing).toBe(false);
+ expect(history.visible).toBe(groups[0].snapshot);
+ expect(history.startPlayback(history.playbackEntries)).toBe(true);
+ expect(history.selectedId).toBe(-1);
+ expect(history.advancePlayback()).toBe(true);
+ expect(history.visible).toBe(groups[1].snapshot);
+ history.select(null);
+ expect(history.playbackEntries).toEqual([]);
+ expect(history.visible).toBe(history.current);
+});
+
+it("starts grouped playback at the parent of a selected original step and restarts after the final group", () => {
+ const history = new PresentationViewHistory();
+ const metadata = { taskId: "task", executionGeneration: 1 };
+ const first = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
+ const child = history.record("chart", "live", "调查", { ...metadata, category: "charts" });
+ history.record("map", "live", "调查", { ...metadata, category: "map" });
+ const groups = mergeAdjacentPresentationHistory(history.entries, "task", "调查");
+ history.select(child);
+ history.startPlayback(groups);
+ expect(history.selectedId).toBe(-first);
+ expect(history.selected?.mergedStepIds).toEqual([first, child]);
+ history.advancePlayback();
+ history.advancePlayback();
+ history.startPlayback(history.playbackEntries);
+ expect(history.selectedId).toBe(-first);
+ history.select(child);
+ expect(history.playbackEntries).toEqual([]);
+ expect(history.selected?.id).toBe(child);
+});
diff --git a/src/features/workbench/utils/presentation-view-history.ts b/src/features/workbench/utils/presentation-view-history.ts
index cf872ea..ba06db7 100644
--- a/src/features/workbench/utils/presentation-view-history.ts
+++ b/src/features/workbench/utils/presentation-view-history.ts
@@ -28,6 +28,11 @@ export type PresentationHistoryEntry = {
id: number;
key?: string;
taskId?: string;
+ executionGeneration?: number;
+ mergedStepIds?: number[];
+ mergedSummary?: string;
+ category?: PresentationHistoryCategory;
+ chartId?: string;
label: string;
source: InvestigationPresentationActivity["source"];
taskTitle: string | null;
@@ -36,12 +41,16 @@ export type PresentationHistoryEntry = {
message?: string;
snapshot: PresentationView;
};
+export type PresentationHistoryCategory = "charts" | "scada" | "map" | "overlay" | "results";
export type PresentationHistoryControls = {
currentEntryId: number | null;
entries: PresentationHistoryEntry[];
selectedId: number | null;
+ mergedEntry?: PresentationHistoryEntry | null;
+ mergedEntries?: PresentationHistoryEntry[];
onSelect: (id: number | null) => void;
playback?: {
+ entries?: PresentationHistoryEntry[];
playing: boolean;
speed: number;
onToggle: () => void;
@@ -66,8 +75,13 @@ export class PresentationViewHistory {
entries: PresentationHistoryEntry[] = [];
selectedId: number | null = null;
playing = false;
- playbackIds: number[] = [];
+ playbackEntries: PresentationHistoryEntry[] = [];
private sequence = 0;
+ private selectedMerged: PresentationHistoryEntry | null = null;
+
+ get playbackIds() {
+ return this.playbackEntries.map((entry) => entry.id);
+ }
get currentEntryId() {
if (this.entries.at(-1)?.status === "running") return null;
@@ -86,7 +100,9 @@ export class PresentationViewHistory {
);
}
get selected() {
- return this.entries.find((entry) => entry.id === this.selectedId) ?? null;
+ if (this.selectedId === this.selectedMerged?.id) return this.selectedMerged;
+ return this.playbackEntries.find((entry) => entry.id === this.selectedId)
+ ?? this.entries.find((entry) => entry.id === this.selectedId) ?? null;
}
get visible() {
return this.selected?.snapshot ?? this.current;
@@ -98,7 +114,7 @@ export class PresentationViewHistory {
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
- metadata: { key?: string; taskId?: string; createdAt?: number } = {}
+ metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
) {
const existing = metadata.key ? this.entries.find((entry) => entry.key === metadata.key) : null;
const id = existing?.id ?? ++this.sequence;
@@ -133,7 +149,7 @@ export class PresentationViewHistory {
label: string,
source: PresentationHistoryEntry["source"],
taskTitle: string | null,
- metadata: { key?: string; taskId?: string; createdAt?: number } = {}
+ metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
) {
const id = this.begin(label, source, taskTitle, metadata);
this.finish(id, "completed");
@@ -144,32 +160,54 @@ export class PresentationViewHistory {
if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running"))
return false;
this.selectedId = id;
+ this.selectedMerged = null;
+ this.playbackEntries = [];
return true;
}
+ selectMerged(entry: PresentationHistoryEntry) {
+ this.pausePlayback();
+ this.selectedMerged = entry;
+ this.selectedId = entry.id;
+ this.playbackEntries = [];
+ }
startPlayback(entries: PresentationHistoryEntry[]) {
- this.playbackIds = entries.filter((entry) => entry.status === "completed").map((entry) => entry.id);
- if (!this.playbackIds.length) return false;
- const index = this.playbackIds.indexOf(this.selectedId ?? -1);
- if (index < 0 || index === this.playbackIds.length - 1) this.selectedId = this.playbackIds[0];
+ const completed = entries.filter((entry) => entry.status === "completed");
+ if (!completed.length) return false;
+ const index = completed.findIndex((entry) => entry.id === this.selectedId ||
+ (this.selectedId !== null && entry.mergedStepIds?.includes(this.selectedId)));
+ this.seekPlayback(completed[index < 0 || index === completed.length - 1 ? 0 : index].id, completed);
this.playing = true;
return true;
}
+ seekPlayback(id: number, entries = this.playbackEntries) {
+ const completed = entries.filter((entry) => entry.status === "completed");
+ const entry = completed.find((entry) => entry.id === id);
+ if (!entry) return false;
+ this.pausePlayback();
+ this.playbackEntries = completed;
+ this.selectedMerged = entry.mergedStepIds ? entry : null;
+ this.selectedId = entry.id;
+ return true;
+ }
advancePlayback() {
if (!this.playing) return false;
- const next = this.playbackIds[this.playbackIds.indexOf(this.selectedId ?? -1) + 1];
+ const index = this.playbackEntries.findIndex((entry) => entry.id === this.selectedId);
+ const next = this.playbackEntries[index + 1];
if (next === undefined) {
this.pausePlayback();
return false;
}
- this.selectedId = next;
+ this.selectedMerged = next.mergedStepIds ? next : null;
+ this.selectedId = next.id;
return true;
}
pausePlayback() { this.playing = false; }
reset() {
this.pausePlayback();
- this.playbackIds = [];
+ this.playbackEntries = [];
this.current = emptyPresentationView();
this.entries = [];
this.selectedId = null;
+ this.selectedMerged = null;
}
}
diff --git a/tests/browser/investigation-chart-windows.e2e.ts b/tests/browser/investigation-chart-windows.e2e.ts
index 63c080b..ee3ab5f 100644
--- a/tests/browser/investigation-chart-windows.e2e.ts
+++ b/tests/browser/investigation-chart-windows.e2e.ts
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
-import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
+import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap } from "./support/mock-investigation-presentation";
const chart = (index: number) => ({
action: "open_chart", chart_id: "pressure", evidence_refs: ["synthetic:pressure"],
@@ -260,6 +260,7 @@ test("playback restores charts without opening windows while live results remain
run_id: task.active_run!.run_id, execution_generation: 1, occurred_at: task.created_at, command }));
const mock = await mockInvestigationPresentation(page, [task], saved);
await selectInvestigation(page, task.title);
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByRole("button", { name: "播放历史", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
@@ -276,7 +277,7 @@ test("playback restores charts without opening windows while live results remain
const progress = panel.getByRole("slider", { name: "历史播放进度" });
await progress.focus();
await page.keyboard.press("ArrowRight");
- await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2.");
+ await expect(panel.getByRole("button", { name: "切换展示动作" })).toHaveText("地图操作");
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await page.keyboard.press("ArrowRight");
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
diff --git a/tests/browser/investigation-dev-tools.e2e.ts b/tests/browser/investigation-dev-tools.e2e.ts
index c08cd54..8620179 100644
--- a/tests/browser/investigation-dev-tools.e2e.ts
+++ b/tests/browser/investigation-dev-tools.e2e.ts
@@ -1,6 +1,6 @@
import { INVESTIGATION_DEV_NETWORK } from "../../src/features/workbench/utils/investigation-dev-network";
import { expect, test } from "@playwright/test";
-import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
+import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap, showInvestigationTasks } from "./support/mock-investigation-presentation";
test("live investigation charts remain visible while Dev is open", async ({ page }) => {
const task = investigationFixture(1, "合成爆管展示核查");
@@ -766,36 +766,34 @@ test("live actions continue recording without interrupting a historical view and
const task = investigationFixture(1, "历史回看中的实时调查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
const chart = (id: number) => ({ action: "open_chart", chart_id: "live-history", evidence_refs: ["dev:synthetic"], chart: { title: `压力快照 ${id}`, chart_type: "line", x_data: ["08:00"], series: [{ name: "压力", data: [id] }] } });
await mock.emit(task, chart(1));
await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible();
- const switcher = panel.getByRole("combobox", { name: "切换展示动作" });
- await expect(switcher).toContainText("压力快照 1");
+ const switcher = panel.getByRole("button", { name: "切换展示动作" });
+ await expect(switcher).toHaveText("当前(最新)");
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await switcher.click();
- await expect(page.getByRole("option")).toHaveCount(1);
- await expect(page.getByRole("option", { name: /当前(最新).*压力快照 1/ })).toBeVisible();
+ await expect(page.getByRole("menu", { name: "展示类别", exact: true }).locator("[data-history-group-row]")).toHaveText(["图表"]);
await page.keyboard.press("Escape");
- await expect(panel.getByRole("button", { name: "上一个动作" })).toBeDisabled();
+ await expect(panel.getByRole("button", { name: "上一个动作" })).toBeEnabled();
await mock.emit(task, chart(2));
- await expect(switcher).toContainText("压力快照 2");
+ await expect(switcher).toHaveText("当前(最新)");
await switcher.click();
- await expect(page.getByRole("option")).toHaveCount(2);
- await page.getByRole("option", { name: "1. 打开图表 · 压力快照 1 · 完成", exact: true }).click();
+ await page.getByRole("menuitem", { name: "展开图表步骤", exact: true }).last().click();
+ await page.getByRole("menuitem", { name: "1. 压力快照 1", exact: true }).click();
await expect(panel).toContainText("历史回看");
for (let id = 3; id <= 12; id++) await mock.emit(task, chart(id));
- await expect(switcher).toContainText("1. 打开图表");
+ await expect(switcher).toContainText("1. 压力快照 1");
await switcher.click();
- await expect(page.getByRole("option")).toHaveText([
- /当前(最新).*压力快照 12/,
- ...Array.from({ length: 11 }, (_, index) => `${11 - index}. 打开图表 · 压力快照 ${11 - index} · 完成`)
- ]);
- await expect(page.getByRole("option", { name: /当前(最新).*压力快照 12/ })).toBeVisible();
- await expect(page.getByRole("option", { name: /^12\. 打开图表/ })).toHaveCount(0);
+ await page.getByRole("menuitem", { name: "展开图表步骤", exact: true }).last().click();
+ const historyMenu = page.getByRole("menu", { name: "图表展示步骤", exact: true });
+ await expect(historyMenu.getByRole("menuitem", { name: /^\d+\./ })).toHaveCount(12);
+ await expect(historyMenu.getByRole("menuitem", { name: "12. 压力快照 12", exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible();
await expect(panel.getByRole("group", { name: "压力快照 12", exact: true })).toHaveCount(0);
@@ -803,8 +801,8 @@ test("live actions continue recording without interrupting a historical view and
await expect(panel.getByRole("group", { name: "压力快照 12", exact: true })).toBeVisible();
await panel.getByRole("button", { name: "上一个动作" }).focus();
await page.keyboard.press("Enter");
- await expect(switcher).toContainText("11. 打开图表");
- await expect(panel.getByRole("group", { name: "压力快照 11", exact: true })).toBeVisible();
+ await expect(switcher).toHaveText("图表");
+ await expect(panel.getByRole("group", { name: "压力快照 12", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
@@ -822,20 +820,22 @@ test("selecting an investigation restores saved actions, keeps live continuity a
saved.push({ task_id: completed.task_id, run_id: runId, event_seq: 5, execution_generation: 1, occurred_at: completed.created_at, command: command("已完成调查的压力曲线") });
const mock = await mockInvestigationPresentation(page, [task, completed], saved);
await selectInvestigation(page, task.title);
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
- const switcher = panel.getByRole("combobox", { name: "切换展示动作" });
- await expect(switcher).toContainText("4 个动作");
+ const switcher = panel.getByRole("button", { name: "切换展示动作" });
+ await expect(panel.getByRole("slider", { name: "历史播放进度" })).toHaveAttribute("max", "2");
await expect(panel.getByRole("group", { name: "复核压力对比", exact: true })).toBeVisible();
await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("保存的影响范围");
await switcher.click();
- await expect(page.getByRole("option")).toHaveCount(4);
- await page.getByRole("option", { name: "1. 打开图表 · 初次压力对比 · 完成", exact: true }).click();
+ await expect(page.getByRole("menu", { name: "展示类别", exact: true }).locator("[data-history-group-row]")).toHaveCount(3);
+ await page.getByRole("menuitem", { name: "展开图表步骤", exact: true }).last().click();
+ await page.getByRole("menuitem", { name: "1. 初次压力对比", exact: true }).click();
await expect(panel.getByRole("group", { name: "初次压力对比", exact: true })).toBeVisible();
await expect(panel.getByRole("region", { name: "调查覆盖层" })).toHaveCount(0);
await mock.emit(task, command("当前实时压力"));
await expect(panel.getByRole("status")).toContainText("后续 4 个动作");
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
- await expect(switcher).toContainText("5 个动作");
+ await expect(panel.getByRole("slider", { name: "历史播放进度" })).toHaveAttribute("max", "2");
await expect(panel.getByRole("group", { name: "当前实时压力", exact: true })).toBeVisible();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("saved-investigation-history", { body: await panel.screenshot({ path: testInfo.outputPath("saved-investigation-history.png") }), contentType: "image/png" });
@@ -844,6 +844,7 @@ test("selecting an investigation restores saved actions, keeps live continuity a
await page.getByRole("checkbox", { name: "跟随所选调查", exact: true }).uncheck();
await expect(panel.getByText("已载入历史", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
+ await showInvestigationTasks(page);
const tasks = page.getByRole("region", { name: "工况任务", exact: true });
// Completed tasks can be selected through the all-status filter.
const completedTitle = tasks.getByText(completed.title, { exact: true }).first();
@@ -852,10 +853,13 @@ test("selecting an investigation restores saved actions, keeps live continuity a
if (await all.isVisible()) await all.click();
}
await completedTitle.click();
+ await showInvestigationMap(page);
await expect(panel.getByRole("group", { name: "已完成调查的压力曲线", exact: true })).toBeVisible();
- await expect(switcher).toContainText("1 个动作");
+ await expect(panel.getByRole("slider", { name: "历史播放进度" })).toHaveAttribute("max", "0");
+ await showInvestigationTasks(page);
await tasks.getByText(task.title, { exact: true }).first().click();
- await expect(switcher).toContainText("5 个动作");
+ await showInvestigationMap(page);
+ await expect(panel.getByRole("slider", { name: "历史播放进度" })).toHaveAttribute("max", "2");
await expect(panel.getByRole("group", { name: "当前实时压力", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
diff --git a/tests/browser/investigation-merged-history.e2e.ts b/tests/browser/investigation-merged-history.e2e.ts
new file mode 100644
index 0000000..8109326
--- /dev/null
+++ b/tests/browser/investigation-merged-history.e2e.ts
@@ -0,0 +1,216 @@
+import { expect, test, type Page, type TestInfo } from "@playwright/test";
+import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap } from "./support/mock-investigation-presentation";
+
+const chart = (id: string, title: string) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:reading"],
+ chart: { title, chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力(m)", data: [30, 28] }] } });
+
+async function captureMenus(page: Page, testInfo: TestInfo, name: string) {
+ for (const menu of await page.getByRole("menu").all()) {
+ await menu.evaluate((node) => Promise.all(node.getAnimations().map((animation) => animation.finished.catch(() => undefined))));
+ }
+ const boxes = await Promise.all((await page.getByRole("menu").all()).map((menu) => menu.boundingBox()));
+ const visible = boxes.filter((box) => box !== null);
+ const x = Math.min(...visible.map((box) => box.x));
+ const y = Math.min(...visible.map((box) => box.y));
+ const right = Math.max(...visible.map((box) => box.x + box.width));
+ const bottom = Math.max(...visible.map((box) => box.y + box.height));
+ await testInfo.attach(name, { body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`),
+ clip: { x, y, width: right - x, height: bottom - y } }), contentType: "image/png" });
+}
+
+test("pins the latest original step while new live charts continue arriving", async ({ page }) => {
+ const task = investigationFixture(1, "最新步骤固定回看");
+ const saved = [chart("P1", "压力 1"), chart("P2", "压力 2")].map((command, index) => ({
+ task_id: task.task_id, event_seq: index + 1, run_id: task.active_run!.run_id,
+ execution_generation: 1, occurred_at: task.created_at, command }));
+ const mock = await mockInvestigationPresentation(page, [task], saved);
+ await selectInvestigation(page, task.title, "keyboard");
+ await showInvestigationMap(page);
+ const panel = page.getByLabel("调查实时展示", { exact: true });
+ await panel.getByRole("button", { name: "切换展示动作", exact: true }).click();
+ await page.getByRole("menuitem", { name: "展开图表步骤", exact: true }).click();
+ await page.getByRole("menuitem", { name: "2. 压力 2", exact: true }).click();
+ await expect(panel.getByRole("button", { name: "回到当前", exact: true })).toBeVisible();
+ await mock.emit(task, chart("P3", "压力 3"));
+ await expect(panel.getByText("后续 1 个动作", { exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力 2", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力 3", exact: true })).toHaveCount(0);
+ await panel.getByRole("button", { name: "回到当前", exact: true }).click();
+ await expect(panel.getByRole("group", { name: "压力 3", exact: true })).toBeVisible();
+ expect(mock.mutations).toEqual([]);
+});
+
+test("hover keeps an expanded category stable across its title, arrow and original steps", async ({ page }, testInfo) => {
+ const task = investigationFixture(1, "历史菜单悬停核查");
+ const overlay = { action: "set_overlay", overlay_id: "hover-area", title: "隔离影响范围", mode: "geometry", evidence_id: "synthetic:area",
+ data: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon",
+ coordinates: [[[121.48, 31.1], [121.49, 31.1], [121.49, 31.11], [121.48, 31.1]]] } }] } };
+ const saved = [chart("P1", "测点 P1 压力趋势"), chart("P2", "测点 P2 压力趋势"), overlay].map((command, index) => ({
+ task_id: task.task_id, event_seq: index + 1, run_id: task.active_run!.run_id,
+ execution_generation: 1, occurred_at: task.created_at, command }));
+ const mock = await mockInvestigationPresentation(page, [task], saved);
+ await selectInvestigation(page, task.title, "keyboard");
+ await showInvestigationMap(page);
+ await page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: "切换展示动作", exact: true }).click();
+ const root = page.getByRole("menu", { name: "展示类别", exact: true });
+ const arrow = root.getByRole("menuitem", { name: "展开图表步骤", exact: true });
+ const title = root.getByRole("menuitem", { name: "图表", exact: true });
+ const steps = page.getByRole("menu", { name: "图表展示步骤", exact: true });
+ await arrow.hover();
+ await expect(steps).toBeVisible();
+ await page.evaluate(() => {
+ const changes: string[] = [];
+ const observer = new MutationObserver((records) => {
+ for (const record of records) {
+ if (record.type === "attributes" && record.target instanceof Element && record.target.getAttribute("aria-label") === "展开图表步骤")
+ changes.push(record.target.getAttribute("data-state") ?? "");
+ }
+ });
+ observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["data-state"] });
+ Object.assign(window, { historyHoverChanges: changes });
+ });
+ for (let repeat = 0; repeat < 3; repeat++) {
+ await title.hover();
+ const titleBox = await title.boundingBox();
+ await page.mouse.move(titleBox!.x + 12, titleBox!.y + titleBox!.height / 2, { steps: 8 });
+ await expect(steps).toBeVisible();
+ await arrow.hover();
+ await expect(steps).toBeVisible();
+ await steps.getByRole("menuitem", { name: "1. 测点 P1 压力趋势", exact: true }).hover();
+ await expect(steps).toBeVisible();
+ await arrow.hover();
+ }
+ const changes = await page.evaluate(() => (window as unknown as { historyHoverChanges: string[] }).historyHoverChanges);
+ await testInfo.attach("hover-state-changes", { body: JSON.stringify(changes), contentType: "application/json" });
+ expect(changes).not.toContain("closed");
+ await captureMenus(page, testInfo, "stable-history-hover");
+ await root.getByRole("menuitem", { name: "展开覆盖层步骤", exact: true }).hover();
+ await expect(page.getByRole("menu", { name: "覆盖层展示步骤", exact: true })).toBeVisible();
+ await expect(steps).toHaveCount(0);
+ await arrow.hover();
+ await expect(steps).toBeVisible();
+ await expect(page.getByRole("menu", { name: "覆盖层展示步骤", exact: true })).toHaveCount(0);
+ await title.click();
+ await expect(root).toHaveCount(0);
+ const panel = page.getByLabel("调查实时展示", { exact: true });
+ await expect(panel.getByRole("group", { name: "测点 P1 压力趋势", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P2 压力趋势", exact: true })).toBeVisible();
+ expect(mock.mutations).toEqual([]);
+});
+
+test("adjacent historical rounds share one first-level group and keep their original steps below", async ({ page }, testInfo) => {
+ const task = investigationFixture(1, "历史 SCADA 多轮调查");
+ task.execution_generation = 14;
+ task.active_run!.generation = 14;
+ const saved = [1, 7, 11].flatMap((generation, index) => [1, 2].map((step) => ({
+ task_id: task.task_id, run_id: task.active_run!.run_id, event_seq: index * 2 + step,
+ execution_generation: generation, occurred_at: task.created_at,
+ command: chart(`G${generation}-${step}`, `第 ${generation} 轮压力 ${step}`)
+ })));
+ const mock = await mockInvestigationPresentation(page, [task], saved);
+ await selectInvestigation(page, task.title, "keyboard");
+ await showInvestigationMap(page);
+ const panel = page.getByLabel("调查实时展示", { exact: true });
+ const trigger = panel.getByRole("button", { name: "切换展示动作", exact: true });
+ await trigger.click();
+ const root = page.getByRole("menu", { name: "展示类别", exact: true });
+ await expect(root.locator("[data-history-group-row]")).toHaveText(["图表"]);
+ await root.getByRole("menuitem", { name: "图表", exact: true }).focus();
+ await page.keyboard.press("ArrowRight");
+ const charts = page.getByRole("menu", { name: "图表展示步骤", exact: true });
+ await expect(charts.getByRole("menuitem")).toHaveText([
+ "6. 第 11 轮压力 2", "5. 第 11 轮压力 1", "4. 第 7 轮压力 2", "3. 第 7 轮压力 1", "2. 第 1 轮压力 2", "1. 第 1 轮压力 1"
+ ]);
+ await expect(page.getByText("合并查看", { exact: true })).toHaveCount(0);
+ await captureMenus(page, testInfo, "adjacent-round-submenu");
+ await page.keyboard.press("ArrowLeft");
+ await expect(root).toBeVisible();
+ await root.getByRole("menuitem", { name: "图表", exact: true }).click();
+ await expect(trigger).toHaveText("图表");
+ await expect(panel.getByRole("group", { name: "第 7 轮压力 1", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "第 1 轮压力 1", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "第 11 轮压力 1", exact: true })).toBeVisible();
+ expect(mock.mutations).toEqual([]);
+});
+
+test("first-level titles display merged adjacent steps and arrows expose only their own steps on desktop and narrow screens", async ({ page }, testInfo) => {
+ test.setTimeout(60_000);
+ const task = investigationFixture(1, "SCADA 调查相邻步骤核查");
+ const overlay = { action: "set_overlay", overlay_id: "test-area", title: "隔离影响范围", mode: "geometry", evidence_id: "synthetic:area",
+ data: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon",
+ coordinates: [[[121.48, 31.1], [121.49, 31.1], [121.49, 31.11], [121.48, 31.1]]] } }] } };
+ const saved = [chart("P1", "测点 P1 压力趋势"), chart("P2", "测点 P2 压力趋势"), overlay,
+ chart("P3", "测点 P3 压力趋势"), chart("P4", "测点 P4 压力趋势")].map((command, index) => ({
+ task_id: task.task_id, event_seq: index + 1, run_id: task.active_run!.run_id,
+ execution_generation: 1, occurred_at: task.created_at, command }));
+ const mock = await mockInvestigationPresentation(page, [task], saved);
+ await selectInvestigation(page, task.title, "keyboard");
+ await showInvestigationMap(page);
+ const panel = page.getByLabel("调查实时展示", { exact: true });
+ await expect(panel.getByRole("group", { name: "测点 P4 压力趋势", exact: true })).toBeVisible();
+ const closeWindow = page.getByRole("dialog").getByRole("button", { name: "关闭窗口", exact: true });
+ while (await closeWindow.count()) await closeWindow.first().click();
+ const trigger = panel.getByRole("button", { name: "切换展示动作", exact: true });
+ await trigger.click();
+ const root = page.getByRole("menu", { name: "展示类别", exact: true });
+ await expect(root.locator("[data-history-group-row]")).toHaveText(["图表", "覆盖层", "图表"]);
+ await expect(root).not.toContainText("合并查看");
+ await expect(root).not.toContainText("第 1 轮");
+ await captureMenus(page, testInfo, "adjacent-groups-desktop");
+ await root.getByRole("menuitem", { name: "图表", exact: true }).first().click();
+ await expect(trigger).toHaveText("图表");
+ await expect(panel.getByText("历史回看", { exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P3 压力趋势", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P4 压力趋势", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P1 压力趋势", exact: true })).toHaveCount(0);
+ await expect(panel.getByRole("group", { name: "测点 P2 压力趋势", exact: true })).toHaveCount(0);
+ await expect(panel.getByRole("region", { name: "调查覆盖层" })).toHaveCount(0);
+ await mock.emit(task, chart("P5", "测点 P5 压力趋势"));
+ await expect(panel.getByText("后续 1 个动作", { exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P5 压力趋势", exact: true })).toHaveCount(0);
+ await trigger.click();
+ await root.getByRole("menuitem", { name: "展开图表步骤", exact: true }).first().click();
+ const charts = page.getByRole("menu", { name: "图表展示步骤", exact: true });
+ await expect(charts.getByRole("menuitem")).toHaveText(["6. 测点 P5 压力趋势", "5. 测点 P4 压力趋势", "4. 测点 P3 压力趋势"]);
+ await captureMenus(page, testInfo, "adjacent-group-submenu-desktop");
+ await page.keyboard.press("Escape");
+ await page.setViewportSize({ width: 375, height: 720 });
+ await showInvestigationMap(page);
+ await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
+ await trigger.click();
+ await expect(root.locator("[data-history-group-row]")).toHaveText(["图表", "覆盖层", "图表"]);
+ await root.getByRole("menuitem", { name: "展开图表步骤", exact: true }).last().click();
+ const compactCharts = page.getByRole("menu", { name: "图表展示步骤", exact: true });
+ await expect(compactCharts.getByRole("menuitem")).toHaveText(["返回类别", "2. 测点 P2 压力趋势", "1. 测点 P1 压力趋势"]);
+ const bounds = await compactCharts.boundingBox();
+ expect(bounds!.x).toBeGreaterThanOrEqual(0);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(375);
+ expect(await compactCharts.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
+ await captureMenus(page, testInfo, "adjacent-submenu-narrow");
+ await compactCharts.getByRole("menuitem", { name: "返回类别", exact: true }).click();
+ await expect(root.locator("[data-history-group-row]")).toHaveText(["图表", "覆盖层", "图表"]);
+ await root.getByRole("menuitem", { name: "图表", exact: true }).last().click();
+ await expect(panel.getByRole("group", { name: "测点 P1 压力趋势", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P2 压力趋势", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "测点 P3 压力趋势", exact: true })).toHaveCount(0);
+ await trigger.click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+ await trigger.click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ await trigger.click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+ await panel.getByRole("heading", { name: "调查实时展示", exact: true }).click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ await trigger.click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+ await root.getByRole("menuitem", { name: "展开图表步骤", exact: true }).last().click();
+ await compactCharts.getByRole("menuitem", { name: "1. 测点 P1 压力趋势", exact: true }).click();
+ await expect(panel.getByRole("group", { name: "测点 P2 压力趋势", exact: true })).toHaveCount(0);
+ await panel.getByRole("button", { name: "回到当前", exact: true }).click();
+ await expect(panel.getByRole("group", { name: "测点 P5 压力趋势", exact: true })).toBeVisible();
+ await trigger.click();
+ await root.getByRole("menuitem", { name: "覆盖层", exact: true }).click();
+ await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("隔离影响范围");
+ await expect(panel.getByRole("group", { name: "测点 P1 压力趋势", exact: true })).toHaveCount(0);
+ expect(mock.mutations).toEqual([]);
+});
diff --git a/tests/browser/investigation-retention-playback.e2e.ts b/tests/browser/investigation-retention-playback.e2e.ts
index 2902ee2..0ebd83f 100644
--- a/tests/browser/investigation-retention-playback.e2e.ts
+++ b/tests/browser/investigation-retention-playback.e2e.ts
@@ -1,6 +1,6 @@
-import { expect, test } from "@playwright/test";
+import { expect, test, type Page } from "@playwright/test";
import { MAP_STYLE_TOKENS } from "../../src/features/workbench/map/map-colors";
-import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
+import { investigationFixture, mockInvestigationPresentation, selectInvestigation, showInvestigationMap, showInvestigationTasks } from "./support/mock-investigation-presentation";
const chart = (index: number) => ({ action: "open_chart", chart_id: "pressure-history", evidence_refs: ["synthetic:pressure"], chart: {
title: `压力对比 ${index}`, chart_type: "line", x_data: ["10:00", "10:15"], series: [{ name: "压力(m)", data: [30, 30 - index] }]
@@ -9,12 +9,18 @@ const overlay = { action: "set_overlay", overlay_id: "warning-area", title: "合
type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[121.48,31.1],[121.49,31.1],[121.49,31.11],[121.48,31.1]]] } }]
} };
+async function closeChartWindows(page: Page) {
+ const closeWindow = page.getByRole("dialog").getByRole("button", { name: "关闭窗口", exact: true });
+ while (await closeWindow.count()) await closeWindow.first().click();
+}
+
test("successful charts and warning geometry survive stream loss and failed reconnect", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "断线保留展示核查");
const other = investigationFixture(2, "切换后独立调查");
const mock = await mockInvestigationPresentation(page, [task, other]);
await selectInvestigation(page, task.title);
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
await mock.emit(task, chart(1));
@@ -47,44 +53,70 @@ test("successful charts and warning geometry survive stream loss and failed reco
await connection(true);
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
expect(await geometry()).toEqual(saved);
+ await closeChartWindows(page);
+ await showInvestigationTasks(page);
await page.getByRole("region", { name: "工况任务", exact: true }).getByText(other.title, { exact: true }).first().click();
+ await showInvestigationMap(page);
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toHaveCount(0);
await expect.poll(geometry).toEqual({ type: "FeatureCollection", features: [] });
expect(mock.mutations).toEqual([]);
});
-test("history playback pauses, seeks and finishes on a frozen snapshot while current receives new results", async ({ page }, testInfo) => {
+test("history playback plays adjacent groups, pauses and seeks merged snapshots while current receives new results", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const task = investigationFixture(1, "只读历史播放核查");
- const saved = [1, 2, 3].map((index) => ({ task_id: task.task_id, event_seq: index, run_id: task.active_run!.run_id,
- execution_generation: 1, occurred_at: task.created_at, command: chart(index) }));
+ const groupedChart = (index: number) => ({ ...chart(index), chart_id: `pressure-${index}` });
+ const saved = [groupedChart(1), groupedChart(2), overlay, groupedChart(3), groupedChart(4)].map((command, index) => ({
+ task_id: task.task_id, event_seq: index + 1, run_id: task.active_run!.run_id,
+ execution_generation: 1, occurred_at: task.created_at, command }));
const mock = await mockInvestigationPresentation(page, [task], saved);
- await selectInvestigation(page, task.title);
+ await selectInvestigation(page, task.title, "keyboard");
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
- await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toBeVisible();
- await page.getByRole("dialog", { name: "压力对比 3", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
+ await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toBeVisible();
+ await closeChartWindows(page);
await panel.getByRole("button", { name: "播放历史", exact: true }).click();
await panel.getByRole("button", { name: "暂停历史播放", exact: true }).click();
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
- await page.getByRole("dialog", { name: "压力对比 1", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
+ await expect(panel.getByRole("group", { name: "压力对比 2", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toHaveCount(0);
+ const slider = panel.getByRole("slider", { name: "历史播放进度" });
+ await expect(slider).toHaveAttribute("max", "2");
+ await expect(slider).toHaveValue("0");
+ await expect(panel.getByLabel("历史播放位置")).toHaveText("1/3");
+ await closeChartWindows(page);
await panel.getByRole("combobox", { name: "历史播放速度" }).click();
await page.getByRole("option", { name: "2×", exact: true }).click();
await panel.getByRole("button", { name: "播放历史", exact: true }).click();
- await mock.emit(task, chart(4));
+ await mock.emit(task, groupedChart(5));
await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: "播放历史", exact: true })).toBeVisible();
- await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toHaveCount(0);
- await panel.getByRole("slider", { name: "历史播放进度" }).focus();
+ await expect(panel.getByRole("group", { name: "压力对比 5", exact: true })).toHaveCount(0);
+ await expect(panel.getByLabel("历史播放位置")).toHaveText("3/3");
+ await slider.focus();
+ await page.keyboard.press("ArrowLeft");
+ await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("合成影响示意");
+ await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toHaveCount(0);
+ await expect(panel.getByLabel("历史播放位置")).toHaveText("2/3");
await page.keyboard.press("Home");
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力对比 2", exact: true })).toBeVisible();
+ await expect(panel.getByLabel("历史播放位置")).toHaveText("1/3");
+ await closeChartWindows(page);
await testInfo.attach("history-playback-desktop", { body: await panel.screenshot({ path: testInfo.outputPath("history-playback-desktop.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 375, height: 720 });
+ await showInvestigationMap(page);
+ await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await expect(panel.getByRole("slider", { name: "历史播放进度" })).toBeVisible();
expect(await panel.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
await testInfo.attach("history-playback-narrow", { body: await panel.screenshot({ path: testInfo.outputPath("history-playback-narrow.png") }), contentType: "image/png" });
- await page.getByRole("dialog", { name: "压力对比 1", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
+ await closeChartWindows(page);
+ await panel.getByRole("button", { name: "下一个动作", exact: true }).click();
+ await expect(panel.getByLabel("历史播放位置")).toHaveText("2/3");
+ await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("合成影响示意");
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
- await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toBeVisible();
+ await expect(panel.getByRole("group", { name: "压力对比 5", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
@@ -93,10 +125,11 @@ test("restores a persisted chart when the stream disconnects during display conf
const task = investigationFixture(1, "图表确认阶段断线恢复");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
+ await showInvestigationMap(page);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
await mock.emit(task, chart(1));
- await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("1. ");
+ await expect(panel.getByRole("button", { name: "切换展示动作" })).toContainText("当前(最新)");
await page.evaluate(() => {
const observer = new MutationObserver(() => {
const plot = document.querySelector('[data-presentation-scope="investigation"] [data-investigation-chart="pressure-history"]');
@@ -116,7 +149,7 @@ test("restores a persisted chart when the stream disconnects during display conf
});
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
await expect(panel.getByRole("group", { name: "压力对比 2", exact: true })).toBeVisible();
- await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2 个动作");
+ await expect(panel.getByRole("slider", { name: "历史播放进度" })).toHaveAttribute("max", "0");
await testInfo.attach("chart-recovered-after-interrupted-confirmation", {
body: await panel.screenshot({ path: testInfo.outputPath("chart-recovered-after-interrupted-confirmation.png") }), contentType: "image/png",
});
diff --git a/tests/browser/support/mock-investigation-presentation.ts b/tests/browser/support/mock-investigation-presentation.ts
index 78a1562..d54ae00 100644
--- a/tests/browser/support/mock-investigation-presentation.ts
+++ b/tests/browser/support/mock-investigation-presentation.ts
@@ -130,7 +130,7 @@ export async function mockInvestigationPresentation(page: Page, tasks: Operation
};
}
-export async function selectInvestigation(page: Page, title: string) {
+export async function selectInvestigation(page: Page, title: string, input: "pointer" | "keyboard" = "pointer") {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^(?:打开)?工况任务$/ }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
@@ -142,7 +142,8 @@ export async function selectInvestigation(page: Page, title: string) {
const collapse = panel.getByRole("button", { name: "收起工况任务", exact: true });
if (await collapse.isVisible()) await collapse.click();
}
- await task.click();
+ if (input === "keyboard") await task.press("Enter");
+ else await task.click();
}
export async function showInvestigationMap(page: Page) {