feat(3d): integrate ZJB scene with project context
Generic Container CI/CD / test-build-publish (push) Successful in 17s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 18s

This commit is contained in:
2026-09-11 18:49:55 +08:00
parent 3ebe3328aa
commit 35b6819459
76 changed files with 109136 additions and 22 deletions
@@ -0,0 +1,592 @@
"use client";
import clsx from "clsx";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import Draggable from "react-draggable";
import {
FiCalendar,
FiChevronDown,
FiChevronLeft,
FiChevronRight,
FiPause,
FiPlay,
FiRotateCcw,
FiSkipBack,
FiSkipForward,
FiX,
FiZap,
} from "react-icons/fi";
import {
formatTimelineTime,
getRoundedCurrentTimelineMinutes,
normalizeTimelineMinutes,
} from "@components/olmap/core/Controls/timelineTime";
const DEFAULT_PLAY_INTERVAL_MS = 2_500;
const WEEK_LABELS = ["一", "二", "三", "四", "五", "六", "日"];
const glassClass =
"bg-[rgba(234,244,250,0.62)] [backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [-webkit-backdrop-filter:blur(26px)_saturate(145%)_contrast(96%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.90),inset_0_-1px_0_rgba(112,145,168,0.16),0_18px_50px_rgba(15,43,69,0.20)] ring-1 ring-white/60";
const calendarGlassClass =
"bg-[rgba(238,246,251,0.88)] [backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [-webkit-backdrop-filter:blur(30px)_saturate(140%)_contrast(95%)] [box-shadow:inset_0_1px_0_rgba(255,255,255,0.96),inset_0_-1px_0_rgba(112,145,168,0.18),0_22px_60px_rgba(15,43,69,0.24)] ring-1 ring-white/75";
export type ThreeDimensionalTimelineProps = {
selectedDate: Date;
currentTime: number;
durationMinutes: number;
stepMinutes: number;
disabled?: boolean;
sidePanelOpen?: boolean;
onClose: () => void;
onSelectedDateChange: (date: Date) => void;
onCurrentTimeChange: (minutes: number) => void;
};
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate());
const isSameDay = (left: Date, right: Date) =>
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate();
const addDays = (date: Date, amount: number) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount, 12);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year} / ${month} / ${day}`;
};
export function ThreeDimensionalTimeline({
selectedDate,
currentTime,
durationMinutes,
stepMinutes,
disabled = false,
sidePanelOpen = false,
onClose,
onSelectedDateChange,
onCurrentTimeChange,
}: ThreeDimensionalTimelineProps) {
const timelineRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const [playIntervalMs, setPlayIntervalMs] = useState(DEFAULT_PLAY_INTERVAL_MS);
const [previewTime, setPreviewTime] = useState<number | null>(null);
const safeCurrentTime = normalizeTimelineMinutes(
previewTime ?? currentTime,
0,
durationMinutes,
);
const advance = useCallback(
(direction: 1 | -1) => {
const next = safeCurrentTime + stepMinutes * direction;
onCurrentTimeChange(
next > durationMinutes ? 0 : next < 0 ? durationMinutes : next,
);
},
[durationMinutes, onCurrentTimeChange, safeCurrentTime, stepMinutes],
);
useEffect(() => {
if (!playing || disabled) return;
const intervalId = window.setInterval(() => advance(1), playIntervalMs);
return () => window.clearInterval(intervalId);
}, [advance, disabled, playIntervalMs, playing]);
const marks = useMemo(
() =>
[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const value = Math.round((durationMinutes * ratio) / stepMinutes) * stepMinutes;
return formatTimelineTime(Math.min(durationMinutes, value), 0, durationMinutes);
}),
[durationMinutes, stepMinutes],
);
const resetToCurrentTime = () => {
const now = new Date();
setPlaying(false);
onSelectedDateChange(now);
onCurrentTimeChange(
getRoundedCurrentTimelineMinutes(now, stepMinutes, durationMinutes),
);
};
const commitPreview = (value?: string) => {
const next = Number(value ?? previewTime ?? safeCurrentTime);
if (!Number.isFinite(next)) return;
setPreviewTime(null);
onCurrentTimeChange(next);
};
const progress = durationMinutes > 0
? Math.min(100, Math.max(0, (safeCurrentTime / durationMinutes) * 100))
: 0;
return (
<div
className={clsx(
"pointer-events-none absolute inset-x-2 bottom-2 z-20 flex justify-center transition-[right] duration-200 md:left-4 md:bottom-4",
sidePanelOpen ? "md:right-[416px]" : "md:right-4",
)}
>
<Draggable
nodeRef={timelineRef}
handle=".timeline-drag-handle"
cancel="button, input, select, [role='dialog']"
>
<section
ref={timelineRef}
aria-label="三维场景时间轴"
className={clsx(
glassClass,
"pointer-events-auto relative w-full max-w-[950px] rounded-2xl opacity-95 transition-opacity duration-200 hover:opacity-100",
)}
>
<div className="timeline-drag-handle relative flex h-7 cursor-move touch-none items-center justify-center rounded-t-2xl border-b border-white/40 bg-white/10">
<span aria-hidden="true" className="h-1 w-10 rounded-full bg-slate-400/60 transition-colors hover:bg-slate-500/70" />
<button
type="button"
aria-label="收起时间轴"
title="收起时间轴"
onClick={onClose}
className="absolute right-1 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-lg text-slate-500 transition-[transform,color,background-color] hover:bg-white/60 hover:text-slate-800 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
<FiX />
</button>
</div>
<div className="px-3 pb-3 pt-3 md:px-4 md:pb-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-semibold text-slate-800">结果时间轴</span>
<span className="rounded-md bg-blue-50/60 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 ring-1 ring-blue-200/55">{stepMinutes} 分钟步长</span>
<time className="ml-auto hidden text-xs tabular-nums text-slate-500 sm:block">
{formatDate(selectedDate)} {formatTimelineTime(safeCurrentTime, 0, durationMinutes)}
</time>
</div>
<div className="space-y-2.5">
<div className="grid gap-2.5 sm:grid-cols-[minmax(260px,0.85fr)_minmax(350px,1.15fr)]">
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">数据日期</span>
<div className="flex w-full items-center justify-between gap-3">
<SquareButton label="后退一天" disabled={disabled} onClick={() => onSelectedDateChange(addDays(selectedDate, -1))}>
<FiChevronLeft />
</SquareButton>
<CalendarPicker
selectedDate={selectedDate}
disabled={disabled}
onChange={onSelectedDateChange}
/>
<SquareButton
label="前进一天"
disabled={disabled || startOfDay(selectedDate).getTime() >= startOfDay(new Date()).getTime()}
onClick={() => onSelectedDateChange(addDays(selectedDate, 1))}
>
<FiChevronRight />
</SquareButton>
</div>
</div>
<div className="rounded-xl bg-white/25 px-3 py-2 ring-1 ring-white/40">
<span className="mb-1.5 block text-[11px] font-medium text-slate-500">播放控制</span>
<div className="flex w-full items-center justify-between gap-3">
<PlaybackSpeedPicker
value={playIntervalMs}
disabled={disabled}
onChange={setPlayIntervalMs}
/>
<div className="flex items-center gap-2">
<TimelineButton label={`后退 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(-1)}><FiSkipBack /></TimelineButton>
<TimelineButton label={playing ? "暂停播放" : "播放时间轴"} disabled={disabled} active={playing} onClick={() => setPlaying((value) => !value)}>{playing ? <FiPause /> : <FiPlay className="translate-x-px" />}</TimelineButton>
<TimelineButton label={`前进 ${stepMinutes} 分钟`} disabled={disabled} onClick={() => advance(1)}><FiSkipForward /></TimelineButton>
</div>
<TimelineButton label="回到当前时刻" disabled={disabled} onClick={resetToCurrentTime}><FiRotateCcw /></TimelineButton>
</div>
</div>
</div>
<div className="min-w-0 rounded-xl bg-white/20 px-3 pb-2 pt-2.5 ring-1 ring-white/35">
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-[11px] text-slate-500">查询时刻</span>
<strong className="text-sm font-semibold tabular-nums text-slate-800">{formatTimelineTime(safeCurrentTime, 0, durationMinutes)}</strong>
</div>
<div className="relative">
<input
type="range"
aria-label="三维场景查询时刻"
min={0}
max={durationMinutes}
step={stepMinutes}
value={safeCurrentTime}
disabled={disabled}
onChange={(event) => setPreviewTime(Number(event.target.value))}
onPointerUp={(event) => commitPreview(event.currentTarget.value)}
onKeyUp={(event) => commitPreview(event.currentTarget.value)}
onBlur={(event) => previewTime !== null && commitPreview(event.currentTarget.value)}
className="block h-1.5 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-40 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-[3px] [&::-webkit-slider-thumb]:ring-white/80"
style={{ background: `linear-gradient(90deg, #2563eb 0%, #2563eb ${progress}%, rgba(148,163,184,.42) ${progress}%, rgba(148,163,184,.42) 100%)` }}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-0.5">
{[0, 1, 2, 3, 4].map((mark) => <span key={mark} className="h-1 w-1 rounded-full bg-white/90 shadow-sm" />)}
</div>
</div>
<div aria-hidden="true" className="mt-1.5 flex justify-between text-[10px] tabular-nums text-slate-500">
{marks.map((mark, index) => <span key={`${mark}-${index}`} className={clsx(index > 0 && index < marks.length - 1 && "hidden sm:inline")}>{mark}</span>)}
</div>
</div>
</div>
</div>
</section>
</Draggable>
</div>
);
}
type CalendarView = "days" | "months" | "years";
function CalendarPicker({ selectedDate, disabled, onChange }: { selectedDate: Date; disabled: boolean; onChange: (date: Date) => void }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const calendarRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
const [focusedDate, setFocusedDate] = useState(() => startOfDay(selectedDate));
const [view, setView] = useState<CalendarView>("days");
const [yearPageStart, setYearPageStart] = useState(() => Math.floor(selectedDate.getFullYear() / 12) * 12);
const today = startOfDay(new Date());
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => {
if (!open || view !== "days") return;
window.requestAnimationFrame(() => {
calendarRef.current
?.querySelector<HTMLButtonElement>("[data-calendar-focused='true']")
?.focus();
});
}, [focusedDate, open, view, visibleMonth]);
const days = useMemo(() => {
const firstWeekday = (visibleMonth.getDay() + 6) % 7;
return Array.from({ length: 42 }, (_, index) =>
new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index - firstWeekday + 1, 12),
);
}, [visibleMonth]);
const selectDate = (date: Date) => {
onChange(date);
setOpen(false);
};
const selectToday = () => {
const now = new Date();
onChange(now);
setFocusedDate(startOfDay(now));
setVisibleMonth(new Date(now.getFullYear(), now.getMonth(), 1));
setYearPageStart(Math.floor(now.getFullYear() / 12) * 12);
setView("days");
};
const showDate = (date: Date) => {
const bounded = startOfDay(date).getTime() > today.getTime() ? today : startOfDay(date);
setFocusedDate(bounded);
setVisibleMonth(new Date(bounded.getFullYear(), bounded.getMonth(), 1));
};
const handleCalendarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (view !== "days") return;
let next: Date | null = null;
if (event.key === "ArrowLeft") next = addDays(focusedDate, -1);
if (event.key === "ArrowRight") next = addDays(focusedDate, 1);
if (event.key === "ArrowUp") next = addDays(focusedDate, -7);
if (event.key === "ArrowDown") next = addDays(focusedDate, 7);
if (event.key === "PageUp") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), 12);
if (event.key === "PageDown") next = new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), 12);
const weekday = (focusedDate.getDay() + 6) % 7;
if (event.key === "Home") next = addDays(focusedDate, -weekday);
if (event.key === "End") next = addDays(focusedDate, 6 - weekday);
if (!next) return;
event.preventDefault();
showDate(next);
};
const atLatestPeriod =
view === "days"
? visibleMonth.getFullYear() === today.getFullYear() && visibleMonth.getMonth() >= today.getMonth()
: view === "months"
? visibleMonth.getFullYear() >= today.getFullYear()
: yearPageStart + 11 >= today.getFullYear();
const movePeriod = (direction: -1 | 1) => {
if (view === "days") {
setVisibleMonth((date) => new Date(date.getFullYear(), date.getMonth() + direction, 1));
return;
}
if (view === "months") {
setVisibleMonth((date) => new Date(date.getFullYear() + direction, date.getMonth(), 1));
return;
}
setYearPageStart((year) => year + direction * 12);
};
const title = view === "days"
? `${visibleMonth.getFullYear()} 年 ${visibleMonth.getMonth() + 1} 月`
: view === "months"
? `${visibleMonth.getFullYear()} 年`
: `${yearPageStart} 至 ${yearPageStart + 11} 年`;
const openCalendar = () => {
const selected = startOfDay(selectedDate);
setVisibleMonth(new Date(selected.getFullYear(), selected.getMonth(), 1));
setFocusedDate(selected);
setYearPageStart(Math.floor(selected.getFullYear() / 12) * 12);
setView("days");
setOpen(true);
};
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
aria-label={`选择日期,当前 ${formatDate(selectedDate)}`}
aria-haspopup="dialog"
aria-expanded={open}
disabled={disabled}
onClick={() => {
if (open) setOpen(false);
else openCalendar();
}}
className={clsx(
"flex h-10 min-w-[154px] items-center gap-2 rounded-xl bg-white/48 px-3 text-sm tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
open && "bg-white/78 ring-2 ring-blue-500/35",
)}
>
<FiCalendar className="text-blue-600" />
<span>{formatDate(selectedDate)}</span>
<FiChevronDown className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
ref={calendarRef}
role="dialog"
aria-label="选择数据日期"
onKeyDown={handleCalendarKeyDown}
className={clsx(
calendarGlassClass,
"absolute bottom-[calc(100%+10px)] left-[-46px] z-50 w-[min(312px,calc(100vw-32px))] rounded-2xl p-3 sm:left-0",
)}
>
<div className="mb-2 flex items-center">
<button type="button" aria-label={view === "years" ? "前十二年" : view === "months" ? "上一年" : "上个月"} onClick={() => movePeriod(-1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"><FiChevronLeft /></button>
<button
type="button"
aria-label={view === "days" ? "选择月份" : view === "months" ? "选择年份" : "返回日期"}
aria-live="polite"
onClick={() => setView((current) => current === "days" ? "months" : current === "months" ? "years" : "days")}
className="min-h-10 flex-1 rounded-xl px-2 text-center text-sm font-semibold tabular-nums text-slate-800 transition hover:bg-white/55 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
>
{title}
</button>
<button type="button" onClick={selectToday} className="min-h-10 rounded-xl px-2.5 text-[11px] font-semibold text-blue-700 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">今天</button>
<button type="button" aria-label={view === "years" ? "后十二年" : view === "months" ? "下一年" : "下个月"} disabled={atLatestPeriod} onClick={() => movePeriod(1)} className="grid h-10 w-10 place-items-center rounded-xl text-slate-600 transition hover:bg-white/55 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-25"><FiChevronRight /></button>
</div>
{view === "days" && (
<>
<div className="grid grid-cols-7 text-center">
{WEEK_LABELS.map((label, index) => <span key={label} className={clsx("py-1 text-[10px] font-medium", index > 4 ? "text-blue-600" : "text-slate-500")}>{label}</span>)}
</div>
<div className="grid grid-cols-7 gap-0.5">
{days.map((date) => {
const selected = isSameDay(date, selectedDate);
const current = isSameDay(date, today);
const focused = isSameDay(date, focusedDate);
const outsideMonth = date.getMonth() !== visibleMonth.getMonth();
const future = startOfDay(date).getTime() > today.getTime();
return (
<button
key={date.toISOString()}
type="button"
aria-label={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${current ? ",今天" : ""}`}
aria-pressed={selected}
data-calendar-focused={focused}
tabIndex={focused ? 0 : -1}
disabled={future}
onFocus={() => setFocusedDate(startOfDay(date))}
onClick={() => selectDate(date)}
className={clsx(
"relative grid h-9 w-9 place-items-center rounded-xl text-xs tabular-nums transition-[transform,color,background-color,box-shadow] active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/45 disabled:cursor-not-allowed disabled:opacity-20",
selected
? "bg-blue-600 font-semibold text-white shadow-md shadow-blue-700/20"
: "text-slate-700 hover:bg-white/65 hover:text-blue-700",
outsideMonth && !selected && "text-slate-400",
current && !selected && "font-semibold text-blue-700 after:absolute after:bottom-1 after:h-1 after:w-1 after:rounded-full after:bg-blue-600",
)}
>
{date.getDate()}
</button>
);
})}
</div>
</>
)}
{view === "months" && (
<div role="grid" aria-label={`${visibleMonth.getFullYear()} 年月份`} className="grid grid-cols-3 gap-1 py-1">
{Array.from({ length: 12 }, (_, month) => {
const future = visibleMonth.getFullYear() > today.getFullYear() || (visibleMonth.getFullYear() === today.getFullYear() && month > today.getMonth());
const selected = selectedDate.getFullYear() === visibleMonth.getFullYear() && selectedDate.getMonth() === month;
return (
<button key={month} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { setVisibleMonth(new Date(visibleMonth.getFullYear(), month, 1)); setView("days"); }} className={clsx("min-h-11 rounded-xl text-sm transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{month + 1} 月</button>
);
})}
</div>
)}
{view === "years" && (
<div role="grid" aria-label="选择年份" className="grid grid-cols-3 gap-1 py-1">
{Array.from({ length: 12 }, (_, offset) => yearPageStart + offset).map((year) => {
const future = year > today.getFullYear();
const selected = year === selectedDate.getFullYear();
return (
<button key={year} type="button" role="gridcell" aria-selected={selected} disabled={future} onClick={() => { const month = year === today.getFullYear() ? Math.min(visibleMonth.getMonth(), today.getMonth()) : visibleMonth.getMonth(); setVisibleMonth(new Date(year, month, 1)); setView("months"); }} className={clsx("min-h-11 rounded-xl text-sm tabular-nums transition hover:bg-white/60 hover:text-blue-700 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-20", selected ? "bg-blue-600 font-semibold text-white shadow-sm hover:bg-blue-600 hover:text-white" : "text-slate-700")}>{year}</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
const playbackSpeedOptions = [
{ value: 1000, label: "1.0×", description: "每秒一步" },
{ value: 2500, label: "0.4×", description: "2.5 秒一步" },
{ value: 5000, label: "0.2×", description: "5 秒一步" },
];
function PlaybackSpeedPicker({ value, disabled, onChange }: { value: number; disabled: boolean; onChange: (value: number) => void }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selected = playbackSpeedOptions.find((option) => option.value === value) ?? playbackSpeedOptions[1];
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!wrapperRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
aria-label={`播放速度,当前 ${selected.label}`}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
className={clsx(
"flex h-10 min-w-[86px] items-center gap-2 rounded-xl bg-white/46 px-2.5 text-xs font-medium tabular-nums text-slate-700 ring-1 ring-white/65 transition-[transform,background-color,box-shadow] hover:bg-white/72 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-40",
open && "bg-white/78 ring-2 ring-blue-500/35",
)}
>
<FiZap aria-hidden="true" className="text-sm text-blue-600" />
<span>{selected.label}</span>
<FiChevronDown aria-hidden="true" className={clsx("ml-auto text-xs text-slate-500 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div
role="listbox"
aria-label="选择播放速度"
className={clsx(
calendarGlassClass,
"absolute bottom-[calc(100%+8px)] left-0 z-50 w-36 overflow-hidden rounded-xl p-1.5",
)}
>
{playbackSpeedOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={clsx(
"flex min-h-11 w-full items-center rounded-lg px-3 text-left transition-[transform,color,background-color] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/40",
option.value === value
? "bg-blue-600 text-white shadow-sm shadow-blue-700/20"
: "text-slate-700 hover:bg-white/60 hover:text-blue-700",
)}
>
<span className="text-sm font-semibold tabular-nums">{option.label}</span>
<span className={clsx("ml-auto text-[10px]", option.value === value ? "text-blue-100" : "text-slate-500")}>{option.description}</span>
</button>
))}
</div>
)}
</div>
);
}
function SquareButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-label={label} title={label} disabled={disabled} onClick={onClick} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-white/42 text-base text-slate-600 ring-1 ring-white/60 transition-[transform,color,background-color] hover:bg-blue-50/75 hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35">
{children}
</button>
);
}
function TimelineButton({ label, disabled, active = false, onClick, children }: { label: string; disabled: boolean; active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={clsx(
"grid h-10 w-10 shrink-0 place-items-center rounded-full text-base transition-[transform,color,background-color,box-shadow] duration-150 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-35",
active
? "bg-blue-600 text-white shadow-md shadow-blue-700/25"
: "bg-slate-100/52 text-slate-600 ring-1 ring-white/55 hover:bg-blue-50/80 hover:text-blue-700",
)}
>
{children}
</button>
);
}