Files
torbox-admin/docs/design-system.md
T
jiawei bd1ac6bab8
torbox-admin / deploy (push) Successful in 1m16s
feat: 新增返回顶部按钮
2026-10-06 22:32:36 -07:00

24 KiB
Raw Blame History

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-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