23 KiB
23 KiB
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)驱动。 - 单格 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-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):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 提示消息 | 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-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 |