Files
jiawei 86c96b1a32
torbox-admin / deploy (push) Successful in 2m39s
feat: 新增批量管理及回收站功能
2026-10-09 06:46:39 -07:00

216 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TorBox Admin UI 风格规范
## 1. 定位
TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页资源并存入云端,再以直链或串流取回。官网自称 _Private Infrastructure Provider_,视觉为墨黑底、翠绿强调色、点阵标题字体、6px 圆角。
本项目是自托管的 TorBox 面板,核心任务是**浏览资源并直接播放**:视频默认在网页内播放,PotPlayer 作为扩展的打开方式。风格定为 **「私有云控制台」**:
- 墨黑底 + 翠绿**唯一**强调色,克制、不花哨
- 点阵字标呼应 TorBox 品牌,等宽字体承载一切数据
- 高信息密度,操作直达(播放、复制直链、预览)
- **仅深色主题**,不提供浅色模式
## 2. 颜色
只使用 shadcn 语义变量(`bg-primary`、`text-muted-foreground` 等),不写原始色值,不新增另一套 token。变量定义在 `src/app/globals.css`,`:root` 与 `.dark` 共用同一套深色值,`<html>` 常驻 `dark` class。
| 变量 | 值 | 用途 |
| -------------------------------- | --------------------------- | -------------------------------- |
| `background` | `oklch(0.192 0.015 272.5)` | 页面底色(= TorBox `#12141B`) |
| `card` / `popover` | `oklch(0.223 0.018 270.5)` | 卡片、弹层,比底色抬升一级 |
| `muted` / `secondary` / `accent` | `oklch(0.257 0.02 268)` | 悬停、选中、占位封面、次要按钮 |
| `foreground` | `oklch(0.97 0.005 265)` | 正文 |
| `muted-foreground` | `oklch(0.714 0.019 261)` | 次要文字(对比度 ≈ 7.2:1) |
| `primary` | `oklch(0.713 0.15 164.6)` | 主操作、品牌色(= `#04BF8A`) |
| `primary-foreground` | `oklch(0.159 0.011 268)` | 主按钮文字,深墨色 |
| `border` / `input` | 白色 8% / 12% | 分隔线、输入框描边 |
| `ring` | 同 `primary` | 焦点环 |
| `destructive` | `oklch(0.704 0.191 22.216)` | 错误、失败 |
| `chart-1` / `chart-2` / `chart-3` | 翠绿 / 天蓝 / 琥珀 | 来源分类色:Torrent / Usenet / Web DL |
要点:
- 主按钮用**深墨字**而非白字:白字在翠绿上对比度仅 2.4:1,深墨为 8.2:1。
- 翠绿只给"最重要的一个动作"(播放、登录、品牌标识),同一视图里不要铺满绿色。
- 来源分类只以小圆点(`SourceDot`)出现,不给大面积色块。
## 3. 字体
通过 `next/font/google` 加载,在 `globals.css` 的 `@theme inline` 中映射:
| 工具类 | 字体 | 用于 |
| -------------- | ---------------------------------------- | -------------------------------------- |
| `font-sans` | Geist + PingFang SC / Microsoft YaHei | 正文、标题、按钮 |
| `font-mono` | Geist Mono | 文件大小、扩展名、计数、汇总等**所有数据**,配合 `tabular-nums` |
| `font-display` | Doto(点阵) | **仅** `BrandMark` 中的 "TORBOX" 字标 |
- 中文由系统字体回落,不另加载中文 webfont。
- 不要把 `font-display` 用在中文或正文上(Doto 只有拉丁字符)。
- 品牌小字(如登录页底部的 `PRIVATE · SELF-HOSTED`)使用 `font-mono` + 大字距 + `text-muted-foreground`。
## 4. 圆角、间距、尺寸
- `--radius: 0.375rem`(6px,与官网一致),组件按 shadcn 派生规则取 `rounded-lg` / `rounded-xl`。
- 间距只用 `gap-*`,不用 `space-x/y-*`;常用节奏:控件间 `gap-2`,区块内 `gap-3`,区块间 `gap-8`。
- 内容最大宽度 `sm:max-w-[1920px]`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。
- 等宽等高用 `size-*`,单行截断用 `truncate`,多行截断用 `line-clamp-*`。
## 5. 布局规则
**宏观骨架一律 flex,禁止用 `fixed` / `sticky` / `absolute` 做布局。** 固定顶部的效果靠"外层不滚、内层滚"实现:
```
div.flex.h-dvh.flex-col ← (app)/layout.tsx
├─ header.shrink-0 ← AppHeader,永远在顶部
└─ main.flex.min-h-0.flex-1.flex-col
└─ div.flex.min-h-0.flex-1.flex-col ← 页面
├─ 工具栏.shrink-0 ← 也不随内容滚动(手机宽度下浮在滚动容器上方,见例外二)
└─ div.min-h-0.flex-1.overflow-y-auto ← 唯一的滚动容器
```
- 需要撑满的子项加 `flex-1 min-h-0`(`min-h-0` 不能省,否则内容会把容器撑破)。
- 推到右侧用 `ml-auto`,居中用 `items-center justify-center`。
- **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。
- **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。手机宽度下资源库工具栏也用这种叠层浮在滚动容器上方(`max-md:grid`,宽屏仍是 flex 纵向排列),收起时只做 transform,滚动容器尺寸不变;列表从工具栏下方开始(虚拟器 `paddingStart` 加上工具栏高度)。
- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。标题吸在工具栏露出部分的下沿:`top` 读滚动容器上的 `--toolbar-inset`,由工具栏位移的 MotionValue 逐帧换算,与工具栏同帧移动(宽屏为 0)。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。
- **例外四·返回顶部按钮**:资源库右下角的 `BackToTopButton` 用 `absolute` 贴在滚动容器外层的 `relative` 容器右下角(`right-6 bottom-6`),不随内容滚动;滑入起点的水平偏移小于留白,不会撑出容器、不产生额外滚动条。
- 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。
- shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。
- **浮层层级**:Dialog、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。
### 长列表(虚拟滚动)
资源库使用 TanStack Virtual(`src/app/(app)/components/library/library-list.tsx`),同样不使用定位:
- 滚动容器由页面持有(上面那个唯一的 `overflow-y-auto`),虚拟器只计算该渲染哪些行。
- 分组数据拍平成「提示 / 标题 / 一行卡片 / 空提示」四种行;可见行处于正常文档流,上下各一个占位 `div` 用 `height` 撑出未渲染部分。
- 行的留白写在行自身(`pb-3`、分组标题 `pt-5`),由 `measureElement` 一并测量,虚拟器 `gap` 为 0。
- 行 key 只由资源与分块序号组成,不含列数;列数按容器宽度计算(最多 5 列)。
- 刷新时顶部插入新资源:停在顶部则直接展示新资源,向下浏览时锚定当前行不跳动。
- 默认保持 TorBox 返回的顺序:来源按 torrents → usenet → webdl 合并,资源内文件不重排。工具栏「排序」菜单(`DropdownMenuRadioGroup`)可选最新加入、最早加入、名称、体积最大、即将过期,比较函数集中在 `use-library.ts` 的 `LIBRARY_SORTERS`,缺字段的排在最后。
- 筛选或排序变化回到顶部;滚动快照(锚点行 + 偏移 + 已测量尺寸)存于 sessionStorage,刷新页面后按锚点恢复。
- 展开状态默认全部展开,**不持久化**,刷新后恢复全部展开。store 记录「基准状态 + 单独切换过的资源」,工具栏图标按钮一键全部收起 / 展开;全部切换或在吸顶条上收起后,把当前分组的标题放回视口顶部。
- 分组标题吸顶见第 5 节例外三;行组件 `memo`,滚动时只渲染新进入范围的行。键盘移动时虚拟器 `scrollPaddingStart`、Tab 聚焦时滚动容器的 `scroll-padding-top` 都会留出吸顶标题(3.5rem)与浮动工具栏的高度;滚动快照、收起后回到标题处都以工具栏下沿为视口顶部。
## 6. 组件选型
优先复用 `src/components/ui` 中的 shadcn 组件,`className` 只调布局,不覆盖颜色与字号。
| 需求 | 使用 |
| -------------- | -------------------------------------------- |
| 表单 | `FieldGroup` + `Field` + `FieldLabel` + `InputGroup` |
| 2–5 个选项切换 | `ToggleGroup`(`variant="outline" size="sm" spacing={0}`) |
| 资源分组 | `ItemHeader`(`aria-expanded` 按钮,展开状态在 `library-view-store`);窄屏只显示来源色点、过期时间省略「后过期」,给标题留空间 |
| 文件卡片 | `Card size="sm"` + `CardContent` + `CardFooter`;底栏左侧一个主操作(视频「播放」、图片「预览」,其他文件没有),右侧图标操作(视频多一个 PotPlayer,所有文件都有下载、复制直链),名称放在 `Tooltip` 与 `aria-label`;卡片是 `@container/card`,窄于 186px(手机两列)时主操作只显示图标 |
| 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) |
| 下拉操作 | `DropdownMenu` |
| 侧滑面板 | `Sheet`(右侧滑出、高度撑满、右上角关闭):手机顶栏菜单 |
| 图标按钮说明 | `Tooltip` |
| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放(类似视频网站的播放页):手机全屏,`sm` 起铺满顶栏以下、顶栏保持可见,内容限宽 1920px 居中;非模态(`modal={false}`),看视频时顶栏照常可用,被盖住的 `<main>` 设为 `inert`;不渲染变暗模糊的遮罩(`showOverlay={false}`),点击弹窗外不关闭(`disablePointerDismissal`),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer |
| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 选择模式 | 工具栏「选择」进入:卡片点击改为勾选(Shift 连选),资源标题行有独立勾选框(永久删除)与「快捷选择」菜单;底部 `LibrarySelectionBar` 是列表下方的 `shrink-0` flex 行,提供标记删除 / 恢复 / 永久删除。标记删除只在本地隐藏文件并清图片缓存,「显示范围 · 回收站」中恢复;疑似广告扩展名见 `AD_FILE_EXT` |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` |
| 空状态 | `Empty` |
| 加载占位 | `Skeleton` |
| 快捷键提示 | `Kbd` |
| 动效 | Motion(`motion/react`),见第 10 节 |
按钮层级:
- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「播放」「进入」。
- `secondary`——次要但常用,如「预览」。
- 卡片上的下载、复制直链、PotPlayer 一律是 `ghost` + `icon-sm` 图标按钮,不作为主操作。
- `outline` / `ghost`——工具栏与 header 中的辅助操作、图标按钮。
## 7. 状态
| 状态 | 表现 |
| ------------ | ------------------------------------------------------------ |
| 加载中 | `Skeleton` 复刻真实布局(分组标题 + 卡片网格) |
| 刷新中 | 刷新按钮内 `Spinner` 并禁用,列表保持原内容;手动刷新带 `?fresh=1`,服务端以 `bypass_cache` 绕过 TorBox 列表缓存(torrent 默认 600 秒才更新),自动重新获取仍走缓存 |
| 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作(没有主操作时为下载图标)禁用但可聚焦(`focusableWhenDisabled`),其余图标操作禁用;不进入 M3U 播放列表 |
| 资源已失效 | 下载完成但文件不在服务器上:标题行 `destructive` 色「文件不可用」,卡片同上禁用 |
| 资源快过期 | 距 `expires_at` 不足 3 天:标题行 `ClockAlert` +「N 天后过期」(`muted-foreground`);不足 24 小时或已过期转为 `destructive`;悬停显示具体时间。阈值见 `EXPIRING_WINDOW_MS / EXPIRING_URGENT_MS`;文件已不可用时不重复提示 |
| 演示模式 | 未配置 `TORBOX_API_KEY` 时资源库返回 `server/mock-library.ts` 的模拟数据;header 字标旁 `Badge variant="outline"`「演示数据」(窄屏隐藏);图片文件返回生成的 SVG 占位图,可预览、缩放,其他文件不可播放;未配置 `ACCESS_TOKEN` 时登录框下方 `FieldDescription` 提示演示密钥 |
| 整体失败 | `Alert variant="destructive"` + 「重试」 |
| 部分来源失败 | 每个来源一条 `Alert`,成组排列(`gap-2`),其余数据照常展示 |
| 无数据 | `Empty` + 引导文案 |
| 筛选无结果 | `Empty` +「清除筛选」 |
| 表单错误 | `Field data-invalid` + 控件 `aria-invalid` + `FieldError` |
## 8. 图标
统一 `lucide-react`。按钮内图标加 `data-icon="inline-start|inline-end"`,不加尺寸类。
| 含义 | 图标 |
| ---------- | ---------------- |
| 品牌 | `Box` |
| 播放 | `Play` |
| PotPlayer | `MonitorPlay` |
| 复制直链 | `Copy` |
| 图片预览 | `Eye` |
| 下载 | `Download` |
| 加载失败图 | `ImageOff` |
| 其他文件 | `File` |
| 播放列表 | `ListVideo` |
| 刷新 | `RefreshCw` |
| 退出 | `LogOut` |
## 9. 文案
- 中文、短句、不加句号;按钮用动词(「进入」「预览」「复制直链」)。
- 数据使用等宽字体与千分位:`128 资源 · 1,024 文件 · 3.2 TB`,分隔符为 ` · `。
- 来源名保留英文:Torrent / Usenet / Web DL;扩展名大写显示(MKV、JPG)。
- 文件大小按 1024 进制格式化(`formatBytes`)。
## 10. 动效与交互
### 原则
- **Motion 是唯一的动效库**(`motion/react`、原生侧 `motion`),不手写补间、缓动与帧循环,不新增 CSS keyframes。
- 所有参数(弹簧、时长、延迟、阶梯间隔、涟漪预设)集中在 `src/constants/motion.ts`,调手感只改这一个文件。
- 翠绿只用于「焦点反馈」:准星、光斑、点阵余辉、NEW 呼吸点;错误反馈用 `destructive`。
- 尊重系统「减弱动态效果」:根布局挂 `MotionConfig reducedMotion="user"`,位移类动画自动关闭;自写逻辑用 `useReducedMotion()` 走 `jump` / 不启动。
- 高频更新只写 MotionValue(作为 `style` 或 `motion.*` 的子节点),不 `setState`,不触发 React 渲染。
### 分层(解耦)
| 层 | 内容 | 规则 |
| --- | --- | --- |
| `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 |
| `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` |
| `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey`、`use-hide-on-scroll` | 通用交互,无业务语义;只产出 MotionValue / 几何 / 状态 |
| `src/components/` | `Reticle`、`DotField`、`ScrambleText`、`AnimatedNumber`、`TypeText`、`ImageLightbox`、`VideoPlayer` | 通用展示组件,外观在这里 |
| 页面组件 | `library-list`、`file-card`、`login-stage` 等 | 只做接线:传选择器、参数与回调 |
- 容器级交互 hook 统一签名 `useXxx(containerRef, options)`,在容器上做事件委托,彼此独立;删掉一行调用即可拆掉一个交互。
- 目标靠 data 属性识别(`src/app/(app)/components/library/library-dom.ts`):`data-file-card` 网格单元、`data-card-focus` 卡片主操作、虚拟行 `data-index`。
- 表单等业务组件只通过回调对外通知(如 `LoginForm` 的 `onTyping / onReject / onAccept`),不知道外部如何呈现。
### 交互清单
| 位置 | 交互 | 实现 |
| --- | --- | --- |
| 登录页背景 | 光标划过点亮附近点阵并留下余辉,空闲时停止帧循环 | `DotField` + `lib/dot-field`,静态点由 `bg-dot-grid` 提供、网格对齐 |
| 登录表单 | 键入时从输入框荡开涟漪;密钥错误时卡片抖动并泛起红波;正确时翠绿波扫过全屏后再跳转 | `LoginStage` 把表单回调接到 `DotField.ripple`;`useLogin({ beforeNavigate })` |
| 字标 | TORBOX 乱码后逐字定格;登录页挂载时播放,header 悬停时重播 | `ScrambleText` + `utils/scramble`(Doto 为等宽字体,不抖动) |
| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` |
| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) |
| 视频封面 | 光标附近的点阵亮成翠绿 | `useSpotlight`(`useMotionTemplate` 径向遮罩)+ `bg-dot-grid` 的 `--dot-color / --dot-size` |
| 上次播放 | 打开过的视频封面底色染成翠绿、点阵常亮并标「上次播放」;关掉弹窗后仍保留,换看另一个时转移过去(只标一个,刷新页面后清除) | `LibraryList` 持有 `lastPlayed`,经行 prop `playedFileId` 传给 `FileCard`;封面用 `data-played` 切换样式 |
| 图片封面加载 | 图片就绪前,翠绿点阵被一道光带反复扫亮并显示「加载中」;加载超过 0.15s 才出现,缓存命中不闪;就绪后图片淡入、覆盖层淡出;减弱动态效果时只留静态点阵与转圈 | `ImageCover` + `IMAGE_LOADING`(`animate` 驱动 MotionValue 生成遮罩,不触发 React 渲染) |
| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` |
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
| 视频播放 | 点击视频封面或「播放」全屏打开并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);视频在顶部(手机贴边),下方依次是标题、信息行与操作;视频宽度上限按视口高度换算(`(100dvh - 9rem) × 16/9`,`sm` 起扣掉顶栏用 `14rem`),矮屏时等比缩小居中;关闭按钮在视频左上角、随控制栏显隐,也可点顶栏品牌标识(看视频时变成「返回资源库」按钮,经 `useVideoDialogStore` 登记的关闭方法)、Esc 或返回键 / 返回手势关闭(打开时压入一条历史记录,用其他方式关闭时撤回,不留多余记录);「PotPlayer 播放」交给外部播放器后关闭 | `LibraryVideoDialog` + `VideoPlayer` + `useCloseOnBack`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 |
| 视频播放列表 | 同一资源的视频超过一个时始终显示,无边框,标题行只有「播放列表 3 / 12」:宽屏(`≥ lg`)在右侧占满高度,窄屏在信息行下方占满剩余高度(最低 12rem,更矮时整块滚动),内容多时只滚动列表;点击切换,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 手机顶栏菜单 | 宽度 `< md` 时顶栏右侧的播放列表、退出登录与工具栏的来源 / 下载状态 / 类型三组筛选收进右上角汉堡按钮;点击后菜单从右侧滑出(高度撑满,右上角关闭):筛选按等宽网格排列并带「重置」(只清这三项,保留关键词),下面是 M3U 的「复制 M3U 地址」(手机上没有 PotPlayer,不提供「用 PotPlayer 打开」),退出登录固定在底部;三项中任一项不是「全部」时汉堡图标右上角弹出翠绿提示点,读屏名为「菜单(已启用筛选)」 | `MobileMenu`(`Sheet`)+ `LibraryFilters layout="stacked"`(与工具栏共用);提示点用单格 grid 叠层 + Motion `scale`,参数见 `INDICATOR_DOT`;来源计数只在菜单打开时计算 |
| 手机工具栏 | 宽度 `< md` 时工具栏浮在列表上方,向下浏览时上移滑进顶栏下方、向上浏览或回到顶部附近滑回;列表内容只跟手指走,不会被工具栏推动;吸顶标题贴着工具栏下沿一起移动;同一方向累计滚动过阈值才切换,正在输入搜索时保持展开,收起后 `inert` 不可聚焦 | `useHideOnScroll`(滚动容器上监听方向,只在切换时渲染)+ 单格 grid 叠层 + Motion `y`,参数见 `TOOLBAR_COLLAPSE`;不改高度:改高度会让滚动容器每帧重新布局,内容被额外推开,动画也只能在主线程跑 |
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |