# 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` 共用同一套深色值,`` 常驻 `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`。 - 内容最大宽度 `max-w-7xl`,水平内边距 `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 不属于定位。 - **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 - 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 - shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 - **浮层层级**:Dialog、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 ### 长列表(虚拟滚动) 资源库使用 TanStack Virtual(`src/app/(app)/components/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-pt-14` 都会留出吸顶标题的高度。 ## 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` | | 图标按钮说明 | `Tooltip` | | 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放,`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),标题下注明「TorBox 转码」;底栏提供下载与「PotPlayer 播放」;播放错误说明替换为中文并提示换用 PotPlayer | | 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | | 提示消息 | `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` | 通用交互,无业务语义;只产出 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-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` | | 视频播放 | 点击视频封面或「播放」在弹窗中网页播放并自动开始,焦点交给播放器(空格、方向键等快捷键直接可用);「PotPlayer 播放」交给外部播放器后关闭弹窗 | `LibraryVideoDialog` + `VideoPlayer`;弹窗与灯箱都在列表层只渲染一个,全屏或缩放窗口重建卡片行时不会中断播放 | | 视频播放列表 | 弹窗里同一资源的视频超过一个时,底栏左侧出现「播放列表 3 / 12」开关,列表默认收起(收起时播放器占满宽度),展开状态在弹窗关闭后保留;展开时列表从右侧滑入、播放器随之收窄,收起时滑出(窄屏改为在播放器下方展开 / 收起),参数见 `PLAYLIST_SLIDE`:宽屏在播放器右侧、高度跟随播放器,窄屏在下方限高;点击切换、上一个 / 下一个按钮,播完自动接下一个(最后一个不循环),当前项高亮并滚入视野;切换同时转移封面的「上次播放」标记。Video.js v10 没有播放列表功能(官方列为已知缺口),所以列表与接续逻辑由项目自己实现 | `VideoPlaylist` + `LibraryVideoDialog`(`VideoPlayer` 的 `onEnded`);顺序与资源列表一致,受类型、搜索筛选影响 | | 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | | 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |