feat: 引入图片组件

This commit is contained in:
2026-10-05 20:24:49 -07:00
parent 9bcb2689b1
commit 0203de1168
15 changed files with 647 additions and 54 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# TorBox API Key,仅服务端使用,不会下发到浏览器 # TorBox API Key,仅服务端使用,不会下发到浏览器
# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts),不可播放 # 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts)
TORBOX_API_KEY= TORBOX_API_KEY=
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权 # 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动 # 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
+202
View File
@@ -0,0 +1,202 @@
# 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`。
- 内容最大宽度 `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)与图片预览灯箱内部的定位不受此限。
### 长列表(虚拟滚动)
资源库使用 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` |
| 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) |
| 下拉操作 | `DropdownMenu` |
| 图标按钮说明 | `Tooltip` |
| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` |
| 空状态 | `Empty` |
| 加载占位 | `Skeleton` |
| 快捷键提示 | `Kbd` |
| 动效 | Motion(`motion/react`),见第 10 节 |
按钮层级:
- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「PotPlayer」「进入」。
- `secondary`——次要但常用,如「预览」「下载」。
- `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` |
| 复制直链 | `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` | 通用展示组件,外观在这里 |
| 页面组件 | `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` |
| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` |
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 |
| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` |
| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 |
| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` |
+1
View File
@@ -21,6 +21,7 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"yet-another-react-lightbox": "^3.32.2",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
+25
View File
@@ -44,6 +44,9 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
yet-another-react-lightbox:
specifier: ^3.32.2
version: 3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
zustand: zustand:
specifier: ^5.0.15 specifier: ^5.0.15
version: 5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8)) version: 5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8))
@@ -3159,6 +3162,20 @@ packages:
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yet-another-react-lightbox@3.32.2:
resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==}
engines: {node: '>=14'}
peerDependencies:
'@types/react': ^16 || ^17 || ^18 || ^19
'@types/react-dom': ^16 || ^17 || ^18 || ^19
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
yocto-spinner@1.2.2: yocto-spinner@1.2.2:
resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==}
engines: {node: '>=18.19'} engines: {node: '>=18.19'}
@@ -6575,6 +6592,14 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yet-another-react-lightbox@3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
yocto-spinner@1.2.2: yocto-spinner@1.2.2:
dependencies: dependencies:
yoctocolors: 2.2.0 yoctocolors: 2.2.0
+2 -1
View File
@@ -16,10 +16,11 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
<header className="shrink-0 border-b"> <header className="shrink-0 border-b">
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6"> <div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6">
<BrandMark /> <BrandMark />
{/* 手机宽度下放不下,演示模式只在登录页提示 */}
{mock && ( {mock && (
<Badge <Badge
variant="outline" variant="outline"
className="text-muted-foreground" className="hidden text-muted-foreground sm:inline-flex"
title="未配置 TORBOX_API_KEY,资源库显示的是内置模拟数据,无法播放" title="未配置 TORBOX_API_KEY,资源库显示的是内置模拟数据,无法播放"
> >
演示数据 演示数据
+5 -28
View File
@@ -18,12 +18,6 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -105,12 +99,13 @@ type FileCardProps = {
token: string; token: string;
/** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */ /** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */
status: DownloadStatus; status: DownloadStatus;
/** 打开图片预览(仅图片文件使用),灯箱由列表统一渲染 */
onPreview: () => void;
}; };
export const FileCard = ({ file, token, status }: FileCardProps) => { export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => {
const origin = useOrigin(); const origin = useOrigin();
const copy = useCopy(); const copy = useCopy();
const [previewOpen, setPreviewOpen] = useState(false);
const [imageFailed, setImageFailed] = useState(false); const [imageFailed, setImageFailed] = useState(false);
if (!origin) return null; if (!origin) return null;
@@ -175,7 +170,7 @@ export const FileCard = ({ file, token, status }: FileCardProps) => {
type="button" type="button"
title="预览图片" title="预览图片"
className="flex aspect-video overflow-hidden bg-muted" className="flex aspect-video overflow-hidden bg-muted"
onClick={() => setPreviewOpen(true)} onClick={onPreview}
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
@@ -192,7 +187,7 @@ export const FileCard = ({ file, token, status }: FileCardProps) => {
size="sm" size="sm"
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={() => setPreviewOpen(true)} onClick={onPreview}
{...CARD_FOCUS_PROPS} {...CARD_FOCUS_PROPS}
> >
<EyeIcon data-icon="inline-start" /> <EyeIcon data-icon="inline-start" />
@@ -246,24 +241,6 @@ export const FileCard = ({ file, token, status }: FileCardProps) => {
<TooltipContent>复制直链</TooltipContent> <TooltipContent>复制直链</TooltipContent>
</Tooltip> </Tooltip>
</CardFooter> </CardFooter>
{available && file.kind === "image" && (
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="truncate pr-8">
{file.displayName}
</DialogTitle>
</DialogHeader>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamUrl}
alt={file.displayName}
className="max-h-[75dvh] w-full rounded-lg bg-muted object-contain"
/>
</DialogContent>
</Dialog>
)}
</Card> </Card>
); );
}; };
@@ -0,0 +1,73 @@
// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换
"use client";
import { useMemo } from "react";
import { ImageLightbox, type LightboxImage } from "@/components/image-lightbox";
import type { LibraryItemView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { buildStreamUrl } from "@/utils/media";
import { getItemKey } from "./library-rows";
/** 正在预览的图片:所属资源分组与文件 id */
export type ImagePreviewTarget = { itemKey: string; fileId: number };
type LibraryImagePreviewProps = {
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
items: LibraryItemView[];
token: string;
target: ImagePreviewTarget | null;
onTargetChange: (target: ImagePreviewTarget | null) => void;
};
export const LibraryImagePreview = ({
items,
token,
target,
onTargetChange
}: LibraryImagePreviewProps) => {
const origin = useOrigin();
const itemKey = target?.itemKey;
// 资源未就绪时卡片不提供预览,这里同样视为没有图片
const files = useMemo(() => {
const item =
itemKey === undefined
? undefined
: items.find((entry) => getItemKey(entry) === itemKey);
if (item?.status !== "ready") return [];
return item.files.filter((file) => file.kind === "image");
}, [items, itemKey]);
const images = useMemo<LightboxImage[]>(
() =>
origin
? files.map((file) => ({
src: buildStreamUrl(origin, file.playUrl, file.displayName, token),
title: file.displayName
}))
: [],
[files, origin, token]
);
// 按文件 id 定位,刷新列表后仍停在同一张;这张图已不存在时关闭
const index = target
? files.findIndex((file) => file.id === target.fileId)
: -1;
return (
<ImageLightbox
images={images}
index={Math.max(index, 0)}
open={index >= 0}
onClose={() => onTargetChange(null)}
onIndexChange={(next) => {
// 打开时也会回调一次当前序号,同一张不重复更新
const file = files[next];
if (itemKey !== undefined && file && file.id !== target?.fileId)
onTargetChange({ itemKey, fileId: file.id });
}}
/>
);
};
+71 -23
View File
@@ -28,6 +28,7 @@ import { useElementWidth } from "@/hooks/use-element-width";
import { useFollowHighlight } from "@/hooks/use-follow-highlight"; import { useFollowHighlight } from "@/hooks/use-follow-highlight";
import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav"; import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav";
import type { LibraryItemView } from "@/hooks/use-library"; import type { LibraryItemView } from "@/hooks/use-library";
import { useStuck } from "@/hooks/use-stuck";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLibraryFilterStore } from "@/stores/library-filter-store"; import { useLibraryFilterStore } from "@/stores/library-filter-store";
import { import {
@@ -40,6 +41,10 @@ import type { SourceType } from "@/types/torbox";
import { FileCard } from "./file-card"; import { FileCard } from "./file-card";
import { FileGrid, FileGridItem } from "./file-grid"; import { FileGrid, FileGridItem } from "./file-grid";
import { ItemHeader } from "./item-header"; import { ItemHeader } from "./item-header";
import {
LibraryImagePreview,
type ImagePreviewTarget
} from "./library-image-preview";
import { import {
CARD_FOCUS_SELECTOR, CARD_FOCUS_SELECTOR,
CARD_PROPS, CARD_PROPS,
@@ -114,16 +119,22 @@ const getRestoreOffset = (
* 分组标题用原生 sticky 吸顶(由浏览器合成,滚动时不跑 JS):非首个标题有 20px 上留白, * 分组标题用原生 sticky 吸顶(由浏览器合成,滚动时不跑 JS):非首个标题有 20px 上留白,
* 用 -top-3 藏掉 12px,吸附时与首个标题一样露出 8px 上留白。 * 用 -top-3 藏掉 12px,吸附时与首个标题一样露出 8px 上留白。
*/ */
const rowClassName = (row: LibraryRow) => { const rowClassName = (row: LibraryRow, stuck: boolean) => {
if (row.type === "notice") return "pb-2"; if (row.type === "notice") return "pb-2";
if (row.type === "header") if (row.type === "header")
return cn( return cn(
"stuck-divider sticky z-10 bg-background", "stuck-divider sticky bg-background",
row.first ? "top-0 pt-2" : "-top-3 pt-5" row.first ? "top-0 pt-2" : "-top-3 pt-5",
// 吸附后两侧各外扩 6px,内边距补回,内容位置不变
stuck && "-mx-1.5 px-1.5 z-10"
); );
return "pb-3"; return "pb-3";
}; };
/** 标题行 sticky 的 top(px),与 rowClassName 中的 top-0 / -top-3 对应 */
const getStickyTop = (row: LibraryRow) =>
row.type === "header" && !row.first ? -12 : 0;
type HeaderRow = Extract<LibraryRow, { type: "header" }>; type HeaderRow = Extract<LibraryRow, { type: "header" }>;
type RowViewProps = { type RowViewProps = {
@@ -131,8 +142,10 @@ type RowViewProps = {
index: number; index: number;
columns: number; columns: number;
token: string; token: string;
scrollRef: React.RefObject<HTMLDivElement | null>;
measure: (element: HTMLDivElement | null) => void; measure: (element: HTMLDivElement | null) => void;
onToggle: (row: HeaderRow, index: number) => void; onToggle: (row: HeaderRow, index: number) => void;
onPreview: (target: ImagePreviewTarget) => void;
}; };
/** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */ /** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */
@@ -141,11 +154,26 @@ const RowView = memo(function RowView({
index, index,
columns, columns,
token, token,
scrollRef,
measure, measure,
onToggle onToggle,
onPreview
}: RowViewProps) { }: RowViewProps) {
const [stuckRef, stuck] = useStuck<HTMLDivElement>(
scrollRef,
getStickyTop(row),
row.type === "header"
);
const ref = useCallback(
(element: HTMLDivElement | null) => {
stuckRef.current = element;
measure(element);
},
[stuckRef, measure]
);
return ( return (
<div data-index={index} ref={measure} className={rowClassName(row)}> <div data-index={index} ref={ref} className={rowClassName(row, stuck)}>
{row.type === "notice" && ( {row.type === "notice" && (
<Alert variant="destructive"> <Alert variant="destructive">
<TriangleAlertIcon /> <TriangleAlertIcon />
@@ -167,7 +195,14 @@ const RowView = memo(function RowView({
<FileGrid columns={columns} animated> <FileGrid columns={columns} animated>
{row.files.map((file) => ( {row.files.map((file) => (
<FileGridItem key={file.id} {...CARD_PROPS}> <FileGridItem key={file.id} {...CARD_PROPS}>
<FileCard file={file} token={token} status={row.status} /> <FileCard
file={file}
token={token}
status={row.status}
onPreview={() =>
onPreview({ itemKey: row.itemKey, fileId: file.id })
}
/>
</FileGridItem> </FileGridItem>
))} ))}
</FileGrid> </FileGrid>
@@ -330,6 +365,9 @@ const VirtualRows = ({
[virtualizer, toggleItem] [virtualizer, toggleItem]
); );
// 图片预览:灯箱只渲染一个,按分组取图片
const [preview, setPreview] = useState<ImagePreviewTarget | null>(null);
// 卸载时(如列表被空状态替换)补存一次快照 // 卸载时(如列表被空状态替换)补存一次快照
useEffect( useEffect(
() => () => { () => () => {
@@ -379,8 +417,10 @@ const VirtualRows = ({
index={item.index} index={item.index}
columns={columns} columns={columns}
token={token} token={token}
scrollRef={scrollRef}
measure={virtualizer.measureElement} measure={virtualizer.measureElement}
onToggle={onToggleHeader} onToggle={onToggleHeader}
onPreview={setPreview}
/> />
); );
const renderPiece = (piece: RowPiece) => const renderPiece = (piece: RowPiece) =>
@@ -395,23 +435,31 @@ const VirtualRows = ({
); );
return ( return (
// 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动; <>
// grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 {/* 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动;
<div grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 */}
ref={containerRef} <div
className="grid grid-cols-1 *:col-start-1 *:row-start-1" ref={containerRef}
> className="grid grid-cols-1 *:col-start-1 *:row-start-1"
{/* 可见行处于正常文档流,未渲染部分用占位块撑出;每个资源分组包一层容器,作为标题吸顶的边界 */} >
<div className="flex flex-col"> {/* 可见行处于正常文档流,未渲染部分用占位块撑出;每个资源分组包一层容器,作为标题吸顶的边界 */}
{blocks.map((block) => <div className="flex flex-col">
block.type === "group" ? ( {blocks.map((block) =>
<div key={block.key}>{block.pieces.map(renderPiece)}</div> block.type === "group" ? (
) : ( <div key={block.key}>{block.pieces.map(renderPiece)}</div>
renderPiece(block) ) : (
) renderPiece(block)
)} )
)}
</div>
<Reticle ref={highlight.ref} style={highlight.style} />
</div> </div>
<Reticle ref={highlight.ref} style={highlight.style} /> <LibraryImagePreview
</div> items={items}
token={token}
target={preview}
onTargetChange={setPreview}
/>
</>
); );
}; };
+3
View File
@@ -33,6 +33,8 @@ export type LibraryRow =
| { | {
type: "cards"; type: "cards";
key: string; key: string;
/** 所属资源分组,图片预览只在这一组内切换 */
itemKey: string;
files: LibraryFileView[]; files: LibraryFileView[];
status: DownloadStatus; status: DownloadStatus;
} }
@@ -79,6 +81,7 @@ export const buildLibraryRows = (
rows.push({ rows.push({
type: "cards", type: "cards",
key: `cards:${itemKey}:${start / columns}`, key: `cards:${itemKey}:${start / columns}`,
itemKey,
files: item.files.slice(start, start + columns), files: item.files.slice(start, start + columns),
status: item.status status: item.status
}); });
+2 -1
View File
@@ -32,7 +32,8 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
render={<Button variant="ghost" disabled={!playlistUrl} />} render={<Button variant="ghost" disabled={!playlistUrl} />}
> >
<ListVideoIcon data-icon="inline-start" /> <ListVideoIcon data-icon="inline-start" />
播放列表 {/* 手机宽度下只显示图标,文字留给读屏 */}
<span className="sr-only sm:not-sr-only">播放列表</span>
<ChevronDownIcon data-icon="inline-end" /> <ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-52">
@@ -1,6 +1,8 @@
// 播放流代理接口 // 播放流代理接口
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { guard, jsonError } from "@/server/auth"; import { guard, jsonError } from "@/server/auth";
import { isMockMode } from "@/server/env";
import { getMockImage } from "@/server/mock-library";
import { isSourceType, requestDownloadUrl } from "@/server/torbox"; import { isSourceType, requestDownloadUrl } from "@/server/torbox";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,6 +32,18 @@ const proxy = async (
return jsonError(404, "unknown source"); return jsonError(404, "unknown source");
} }
// 演示模式:图片返回生成的占位图,其他文件仍提示不可播放
const mockImage = isMockMode()
? getMockImage(type, Number(id), Number(fileId))
: null;
if (mockImage)
return new Response(method === "HEAD" ? null : mockImage, {
headers: {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "no-store"
}
});
try { try {
const url = await requestDownloadUrl(type, id, fileId); const url = await requestDownloadUrl(type, id, fileId);
const range = req.headers.get("range"); const range = req.headers.get("range");
+164
View File
@@ -0,0 +1,164 @@
// 图片预览灯箱:基于 yet-another-react-lightbox,支持缩放与组内循环切换
"use client";
import { useMemo } from "react";
import { motion } from "motion/react";
import {
ChevronLeftIcon,
ChevronRightIcon,
ImageOffIcon,
XIcon,
ZoomInIcon,
ZoomOutIcon
} from "lucide-react";
import Lightbox, {
type Labels,
type Render,
type Slide,
type SlotStyles
} from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Counter from "yet-another-react-lightbox/plugins/counter";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css";
import "yet-another-react-lightbox/plugins/counter.css";
import { Spinner } from "@/components/ui/spinner";
import { LIGHTBOX } from "@/constants/motion";
export type LightboxImage = {
src: string;
/** 图片名称:显示在左上角,同时作为 alt */
title?: string;
};
type ImageLightboxProps = {
/** 同一组图片:只在这一组内切换,首尾循环 */
images: readonly LightboxImage[];
/** 当前图片在 images 中的序号 */
index: number;
open: boolean;
/** 关闭动画结束后调用 */
onClose: () => void;
/** 切换图片后调用;父组件据此更新 index,重新渲染时仍停在当前图片 */
onIndexChange?: (index: number) => void;
};
const LABELS: Labels = {
Lightbox: "图片预览",
"Photo gallery": "图片",
Carousel: "图片列表",
Slide: "图片",
"{index} of {total}": "第 {index} 张,共 {total} 张",
Previous: "上一张",
Next: "下一张",
Close: "关闭",
"Zoom in": "放大",
"Zoom out": "缩小"
};
/** 图标统一用 lucide;加载超过一小段时间才淡入转圈 */
const RENDER: Render = {
iconPrev: () => <ChevronLeftIcon className="size-8" strokeWidth={1.5} />,
iconNext: () => <ChevronRightIcon className="size-8" strokeWidth={1.5} />,
iconClose: () => <XIcon className="size-6" />,
iconZoomIn: () => <ZoomInIcon className="size-6" />,
iconZoomOut: () => <ZoomOutIcon className="size-6" />,
iconLoading: () => (
<motion.span
className="flex"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ ...LIGHTBOX.loadingFade, delay: LIGHTBOX.loadingDelay }}
>
<Spinner className="size-8 text-muted-foreground" aria-label="加载中" />
</motion.span>
),
iconError: () => (
// 占位层的 line-height 为 0,这里恢复正常行高
<span className="flex flex-col items-center gap-2 text-sm leading-normal text-muted-foreground">
<ImageOffIcon className="size-10" strokeWidth={1.5} aria-hidden="true" />
图片加载失败
</span>
)
};
/** 只有一张时不循环,也不显示左右切换按钮 */
const SINGLE_RENDER: Render = {
...RENDER,
buttonPrev: () => null,
buttonNext: () => null
};
/** 灯箱配色映射到现有主题变量,不新增颜色 */
const STYLES: SlotStyles = {
root: {
"--yarl__color_backdrop": "var(--background)",
"--yarl__color_button": "var(--muted-foreground)",
"--yarl__color_button_active": "var(--foreground)",
"--yarl__color_button_disabled":
"color-mix(in oklab, var(--muted-foreground) 35%, transparent)",
"--yarl__slide_captions_container_background":
"linear-gradient(var(--background), transparent)",
"--yarl__slide_title_color": "var(--foreground)",
"--yarl__slide_title_font_size": "0.875rem",
"--yarl__slide_title_font_weight": 500,
// 计数移到底部,左上角留给图片名称;自定义属性写 unset 等于未设置,会退回默认的 0,所以用 auto
"--yarl__counter_top": "auto",
"--yarl__counter_bottom": 0,
"--yarl__counter_right": 0,
"--yarl__counter_filter": "none"
}
};
const ZOOM = {
/** 最大放大到图片原始像素的 2 倍,小图也能放大查看 */
maxZoomPixelRatio: 2,
/** 鼠标滚轮直接缩放 */
scrollToZoom: true
};
const CONTROLLER = { closeOnBackdropClick: true, closeOnPullDown: true };
/**
* 通用图片预览:传入一组图片和当前序号,只在这一组内左右切换(首尾循环),
* 支持滚轮、双击、双指与工具栏按钮缩放。受控组件,由父组件管理开关与当前序号。
*/
export const ImageLightbox = ({
images,
index,
open,
onClose,
onIndexChange
}: ImageLightboxProps) => {
// 灯箱在 slides 引用变化时会按 index 重置,这里保证 images 不变时引用不变
const slides = useMemo<Slide[]>(
() => images.map(({ src, title }) => ({ src, alt: title, title })),
[images]
);
const single = slides.length <= 1;
return (
<Lightbox
open={open && slides.length > 0}
close={onClose}
index={index}
slides={slides}
plugins={single ? [Captions, Zoom] : [Captions, Counter, Zoom]}
carousel={{ finite: single }}
render={single ? SINGLE_RENDER : RENDER}
labels={LABELS}
styles={STYLES}
zoom={ZOOM}
controller={CONTROLLER}
counter={{
container: {
className:
"pointer-events-none text-center font-mono text-sm tabular-nums"
}
}}
on={{ view: ({ index: next }) => onIndexChange?.(next) }}
/>
);
};
+7
View File
@@ -68,6 +68,13 @@ export const TYPEWRITER = {
cursorBlink: { duration: 0.55, repeat: Infinity, repeatType: "reverse" } cursorBlink: { duration: 0.55, repeat: Infinity, repeatType: "reverse" }
} as const; } as const;
/** 图片预览灯箱 */
export const LIGHTBOX = {
/** 加载超过这个时间(秒)才显示转圈,缓存命中时不闪一下 */
loadingDelay: 0.4,
loadingFade: { duration: 0.2 }
} as const;
/** 登录卡片在密钥错误时的抖动 */ /** 登录卡片在密钥错误时的抖动 */
export const SHAKE = { export const SHAKE = {
x: [0, -10, 10, -6, 6, -2, 0], x: [0, -10, 10, -6, 6, -2, 0],
+32
View File
@@ -0,0 +1,32 @@
// 判断 sticky 元素是否已吸附在滚动容器顶部的 hook
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* 元素是否已吸附在滚动容器顶部;ref 挂到 sticky 元素上。
* stickyTop 是该元素 sticky 的 top 值(px,可为负)。观察边界比吸附位置低 1px,
* 吸附后元素始终被裁掉 1px,IntersectionObserver 只在跨过这条线时回调,滚动时不跑 JS。
*/
export const useStuck = <T extends HTMLElement>(
scrollRef: RefObject<HTMLElement | null>,
stickyTop: number,
enabled = true
) => {
const ref = useRef<T | null>(null);
const [stuck, setStuck] = useState(false);
useEffect(() => {
const node = ref.current;
const root = scrollRef.current;
if (!enabled || !node || !root) return;
const observer = new IntersectionObserver(
([entry]) =>
setStuck(entry.boundingClientRect.top < (entry.rootBounds?.top ?? 0)),
{ root, rootMargin: `${-(stickyTop + 1)}px 0px 0px 0px`, threshold: 1 }
);
observer.observe(node);
return () => observer.disconnect();
}, [scrollRef, stickyTop, enabled]);
return [ref, stuck] as const;
};
+45
View File
@@ -6,6 +6,7 @@ import type {
LibraryResponse, LibraryResponse,
SourceType SourceType
} from "@/types/torbox"; } from "@/types/torbox";
import { getBaseName, getMediaKind } from "@/utils/media";
const HOUR = 60 * 60 * 1000; const HOUR = 60 * 60 * 1000;
const MB = 1024 ** 2; const MB = 1024 ** 2;
@@ -263,3 +264,47 @@ export const getMockLibrary = (now = Date.now()): LibraryResponse => ({
items: MOCK_ITEMS.map((mock, index) => toLibraryItem(mock, index, now)), items: MOCK_ITEMS.map((mock, index) => toLibraryItem(mock, index, now)),
errors: {} errors: {}
}); });
const escapeXml = (text: string) =>
text.replace(
/[&<>"]/g,
(char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char]!
);
/** 演示图片:渐变底 + 点阵 + 文件名;每 4 张有 1 张竖图,用来检查不同比例的显示与缩放 */
const renderPlaceholderSvg = (title: string, seed: number) => {
const hue = (seed * 47) % 360;
const [width, height] = seed % 4 === 0 ? [1080, 1440] : [1600, 1000];
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(${hue} 55% 34%)"/>
<stop offset="1" stop-color="hsl(${(hue + 60) % 360} 60% 12%)"/>
</linearGradient>
<pattern id="dots" width="14" height="14" patternUnits="userSpaceOnUse">
<circle cx="7" cy="7" r="1.2" fill="rgba(255,255,255,.18)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<rect width="100%" height="100%" fill="url(#dots)"/>
<text x="50%" y="50%" text-anchor="middle" font-family="monospace" font-size="${width / 18}" fill="rgba(255,255,255,.92)">${escapeXml(title)}</text>
<text x="50%" y="${height / 2 + width / 16}" text-anchor="middle" font-family="monospace" font-size="${width / 56}" fill="rgba(255,255,255,.6)">${width} × ${height} · 演示图片</text>
</svg>`;
};
/**
* 演示模式下图片文件的内容:生成一张 SVG 占位图,缩略图与图片预览都能正常显示。
* 不是图片、资源未就绪或找不到文件时返回 null,按演示模式不可播放处理。
*/
export const getMockImage = (
source: SourceType,
id: number,
fileId: number
) => {
const mock = MOCK_ITEMS[id - 1000];
if (!mock || mock.source !== source || (mock.status ?? "ready") !== "ready")
return null;
const file = mock.files[fileId - 1];
if (!file || getMediaKind(file.name, file.mimetype) !== "image") return null;
return renderPlaceholderSvg(getBaseName(file.name), id * 31 + fileId * 7);
};