From 03827a2fc05019c7080c367c27ab28d1c68d15b9 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 12 Nov 2026 12:54:09 +0800 Subject: [PATCH] feat(workbench): group live presentation steps into expandable categories Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback. The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes. Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide. --- docs/investigation-dev-tools.md | 4 +- .../investigation-presentation-panel.tsx | 10 +- .../components/presentation-history-menu.tsx | 132 +++++++++++ .../presentation-history-switcher.test.tsx | 91 +++++++- .../presentation-history-switcher.tsx | 33 ++- .../use-investigation-presentation.test.tsx | 12 + .../hooks/use-investigation-presentation.ts | 44 +++- .../investigation-presentation-archive.ts | 15 +- .../utils/merge-presentation-history.test.ts | 139 +++++++++++ .../utils/merge-presentation-history.ts | 109 +++++++++ .../utils/presentation-history-categories.ts | 67 ++++++ .../utils/presentation-view-history.test.ts | 52 +++++ .../utils/presentation-view-history.ts | 60 ++++- .../investigation-chart-windows.e2e.ts | 5 +- tests/browser/investigation-dev-tools.e2e.ts | 54 +++-- .../investigation-merged-history.e2e.ts | 216 ++++++++++++++++++ .../investigation-retention-playback.e2e.ts | 65 ++++-- .../mock-investigation-presentation.ts | 5 +- 18 files changed, 1029 insertions(+), 84 deletions(-) create mode 100644 src/features/workbench/components/presentation-history-menu.tsx create mode 100644 src/features/workbench/utils/merge-presentation-history.test.ts create mode 100644 src/features/workbench/utils/merge-presentation-history.ts create mode 100644 src/features/workbench/utils/presentation-history-categories.ts create mode 100644 tests/browser/investigation-merged-history.e2e.ts 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({ > - onSelect(value === "current" ? null : Number(value))} > @@ -72,7 +87,7 @@ 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}