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.
145 lines
7.0 KiB
TypeScript
145 lines
7.0 KiB
TypeScript
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: "已取消" };
|
||
|
||
export function PresentationHistorySwitcher({
|
||
entries,
|
||
currentEntryId,
|
||
selectedId,
|
||
mergedEntry,
|
||
mergedEntries,
|
||
onSelect,
|
||
playback
|
||
}: PresentationHistoryControls) {
|
||
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
|
||
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 ||
|
||
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 (
|
||
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
|
||
<div className="flex min-w-0 items-center gap-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="size-8 shrink-0"
|
||
aria-label="上一个动作"
|
||
disabled={index <= 0}
|
||
onClick={() => onSelect(available[index - 1].id)}
|
||
>
|
||
<ChevronLeft size={14} aria-hidden />
|
||
</Button>
|
||
{entries.some((entry) => entry.taskId) ? (
|
||
<PresentationHistoryMenu entries={entries} currentEntryId={currentEntryId}
|
||
selectedId={selectedId} mergedEntry={mergedEntry} mergedEntries={mergedEntries} onSelect={onSelect} />
|
||
) : <Select
|
||
value={selectedId === null ? "current" : String(selectedId)}
|
||
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
|
||
>
|
||
<SelectTrigger
|
||
aria-label="切换展示动作"
|
||
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent
|
||
position="popper"
|
||
sideOffset={4}
|
||
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||
>
|
||
<SelectItem value="current" className="text-xs">
|
||
当前(最新)
|
||
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
|
||
{entries.length} 个动作
|
||
</SelectItem>
|
||
{entries
|
||
.slice()
|
||
.reverse()
|
||
.map((entry, position) =>
|
||
entry.id === currentEntryId && entry.id !== selectedId ? null : (
|
||
<SelectItem
|
||
key={entry.id}
|
||
value={String(entry.id)}
|
||
disabled={entry.status === "running"}
|
||
className="whitespace-normal text-xs [&>span:last-child]:break-words"
|
||
>
|
||
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
|
||
</SelectItem>
|
||
)
|
||
)}
|
||
</SelectContent>
|
||
</Select>}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
className="size-8 shrink-0"
|
||
aria-label="下一个动作"
|
||
disabled={selectedId === null}
|
||
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
|
||
>
|
||
<ChevronRight size={14} aria-hidden />
|
||
</Button>
|
||
{selectedId !== null ? (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="sm"
|
||
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
|
||
onClick={() => onSelect(null)}
|
||
>
|
||
<Radio size={12} aria-hidden />
|
||
回到当前
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{playback ? (
|
||
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
|
||
<Button type="button" variant="ghost" size="icon-sm" className="size-8 shrink-0 text-muted-foreground after:inset-0"
|
||
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
|
||
title={playback.playing ? "暂停历史播放" : "播放历史"}
|
||
disabled={!playable.length} onClick={playback.onToggle}>
|
||
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
|
||
</Button>
|
||
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
|
||
min={0} max={Math.max(0, playable.length - 1)} step={1} value={progress}
|
||
disabled={playable.length < 2}
|
||
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
|
||
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
|
||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
|
||
{playable.length ? progress + 1 : 0}/{playable.length}
|
||
</span>
|
||
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
|
||
<SelectTrigger aria-label="历史播放速度"
|
||
className="form-field-inset h-8 w-[54px] shrink-0 justify-center gap-1 border-0 px-1.5 text-xs tabular-nums text-muted-foreground shadow-none hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground [&>svg]:size-3">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper" align="end" sideOffset={4} className="workbench-shadcn-surface z-[90] min-w-[72px]">
|
||
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|