fix(web): align React UI with legacy reader UX
Unify media secondary page chrome, loading states, playback controls, favorites filtering, and source/library screens with the legacy reader experience.
No files matched your search
@@ -56,8 +56,8 @@
|
||||
script: cd web && npm run ui:review:only
|
||||
- name: archive-screenshots
|
||||
script: |
|
||||
echo "UI 评审截图:docs/ui-review/"
|
||||
ls -la docs/ui-review/
|
||||
echo "UI 评审产物:artifacts/ui-review/react-app/"
|
||||
ls -la artifacts/ui-review/react-app/
|
||||
|
||||
main:
|
||||
push:
|
||||
|
||||
@@ -24,14 +24,6 @@ server/data/
|
||||
# web client build output (regenerated by `npm run build`)
|
||||
web/dist/
|
||||
|
||||
# UI review screenshots: regenerated by `npm run ui:review`, and a binary diff on
|
||||
# every run is noise in a review. The *report* is committed — that is the part a
|
||||
# reviewer reads, and the part CI asserts on.
|
||||
docs/ui-review/*.png
|
||||
docs/ui-review/mobile/*.png
|
||||
docs/ui-review/p1/*.png
|
||||
docs/ui-review/p2/*.png
|
||||
|
||||
# Generated media validation artifacts
|
||||
/artifacts/
|
||||
/docs/media-*-result.json
|
||||
|
||||
@@ -26,18 +26,14 @@ The project README introduces reader. Use the guides below for installation, eve
|
||||
| 文档 / Document | 内容 / Contents |
|
||||
| --- | --- |
|
||||
| [架构与数据边界 / Architecture](architecture.md) | 数据目录、身份、同步和客户端架构 / Data boundaries, identity, sync, client architecture |
|
||||
| [前端 React 架构](frontend-react-migration.md) | Web 入口、Provider、路由、feature 边界和长生命周期资源 |
|
||||
| [API](api.md) | 鉴权、书籍、资源、进度、文件管理、OPDS 与影音 / Auth, books, resources, progress, file management, OPDS and media |
|
||||
| [界面规范 / UI guidelines](ui.md) | 布局、交互、可访问性 / Layout, interaction, accessibility |
|
||||
|
||||
## 验证记录 · Review records
|
||||
|
||||
这些是特定功能交付时的验证记录,当前结果以对应提交的测试和 CI 输出为准。
|
||||
These records describe checks at specific delivery points. Use tests and CI for the current commit's results.
|
||||
|
||||
- [UI 评审工具 / UI review tooling](ui-review/README.md)
|
||||
- [书库与阅读界面 / Library and reader UI](ui-review/collections-ux.md)
|
||||
|
||||
- [P1 阅读工具实施与验证](p1-progress-2026-09-23.md)
|
||||
特定功能的验证产物写入被 Git 忽略的 `artifacts/` 目录;当前结果以对应提交的测试和 CI 输出为准。
|
||||
Feature review artifacts are written to the ignored `artifacts/` directory. Use tests and CI for the current commit's results.
|
||||
|
||||
- [互通与客户端覆盖](ecosystem-roadmap.zh-CN.md)
|
||||
|
||||
|
||||
@@ -318,22 +318,23 @@ Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本
|
||||
|
||||
### UI 框架:完整 React 应用
|
||||
|
||||
前端现在由 React 19 负责组件树和页面生命周期,入口是
|
||||
`web/src/main.tsx` 的 `createRoot()`。所有登录后页面都挂在
|
||||
`AppProviders` 下,由成熟的 Provider 统一承载运行时、服务端状态和设计系统:
|
||||
前端由 React 19 负责应用页面和页面生命周期,入口是
|
||||
`web/src/main.tsx` 的 `createRoot()`。启动阶段先由
|
||||
`web/src/app/runtime.ts` 组装平台、API、同步、离线、播放器和设置资源,再由
|
||||
`AppProviders` 把它们注入 React 树:
|
||||
|
||||
- `react-router-dom` 的 `createHashRouter` 负责路由、嵌套路由、参数和错误边界;
|
||||
- `react-router-dom` 的 `createHashRouter` 负责 Hash 路由、嵌套路由、参数、Outlet 和错误边界;
|
||||
- `@tanstack/react-query` 的 `QueryClientProvider` 负责服务端状态、缓存、加载、重试和失效;
|
||||
- Zustand stores 负责认证、设置、同步和播放器等跨页面客户端状态;
|
||||
- Mantine 提供基础组件、主题和通知,Lucide React 提供统一图标;
|
||||
- `RuntimeContext` 只暴露 API、同步、播放和设置等应用运行时依赖。
|
||||
- `RuntimeContext` 暴露 API、同步、播放、设置和平台能力,页面不自行组装这些依赖。
|
||||
|
||||
业务按 `features/<domain>` 拆分,每个领域拥有自己的 `pages`、`components`、
|
||||
`queries`、`mutations`、`services` 和 `styles`。页面只组合查询、变更和组件,
|
||||
不再通过单文件的 URL 判断来调度所有屏幕。影音的实现位于
|
||||
`web/src/features/media`,阅读器仍保留一个命令式排版边界,因为它需要直接测量
|
||||
`getBoundingClientRect()`、维护 shadow root 和处理章节分页;这个边界由
|
||||
`ReaderPage` 的 effect 创建和销毁,不会把整个应用退回到手写 Screen 调度。
|
||||
业务按 `features/<domain>` 拆分,每个领域按实际需要拥有 `pages`、`components`、
|
||||
`queries`、`mutations`、`services` 和 `styles`。影音实现位于
|
||||
`web/src/features/media`;登录、书架、书库、书源、设置和阅读页面也通过各自
|
||||
feature 接入同一棵路由树。旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由
|
||||
和 `mountUI` 已从生产入口移除。`web/src/app.ts` 与
|
||||
`app/legacy-compat.ts` 只保留 Android 诊断和旧测试需要的兼容形状,不负责页面调度。
|
||||
|
||||
React 组件只声明布局和状态,副作用放在 effect、query 或 service 中。这样登录、
|
||||
书架、书库、书源、设置和影音页面共享同一套生命周期语义,加载、错误、空状态和
|
||||
@@ -344,7 +345,7 @@ React 组件只声明布局和状态,副作用放在 effect、query 或 servic
|
||||
界面的第二个结构性问题不是「怎么画」,而是「在哪」。最初的 shell 用
|
||||
`showShelf()` / `openBook()` / `showManager()` 加一个 `pendingBook` 字段来换屏,
|
||||
URL 从头到尾不变。手机上这一点几乎看不见——直到读者按返回键:Android 的返回手势
|
||||
交给 Activity,Activity 看到 WebView 没有历史,于是**直接从书架上退出应用**。
|
||||
交给 Activity,Activity 看到 WebView 没有历史,于是直接从书架上退出应用。
|
||||
浏览器上则更糟,地址栏是自托管书库唯一的分发方式,而它既不能分享也不能收藏:
|
||||
一本书链接不出去,刷新永远回到书架。
|
||||
|
||||
@@ -363,31 +364,49 @@ URL 从头到尾不变。手机上这一点几乎看不见——直到读者按
|
||||
不再由每个 Screen 自己维护一份 history。嵌套页面通过显式返回按钮回到父路由,浏览器
|
||||
的 Back/Forward 和刷新都能重建同一页面状态。
|
||||
|
||||
当前核心路由是:
|
||||
当前路由树的稳定入口包括:
|
||||
|
||||
| 路由 | 页面 |
|
||||
| --- | --- |
|
||||
| `#/shelf` | 书架 |
|
||||
| `#/library`、`#/library/files` | 书库浏览、文件管理 |
|
||||
| `#/sources`、`#/settings` | 书源、设置 |
|
||||
| `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理 |
|
||||
| `#/sources` 及 `#/sources/...` | 书源中心、浏览、凭据、扩展和插件管理 |
|
||||
| `#/settings` | 兼容旧深链接,重定向到书架 |
|
||||
| `#/book/:bookId` | 阅读器 |
|
||||
| `#/media/:channel` | 影音频道首页 |
|
||||
| `#/media/:channel/items/:itemId` | 影音详情 |
|
||||
| `#/media/:channel` | 影音频道目录 |
|
||||
| `#/media/:channel/items/:itemId` | 影音详情及管理子页 |
|
||||
| `#/media/:channel/player?item=&part=` | 播放器 |
|
||||
| `#/media/:channel/settings/...` | 影音设置及管理子页 |
|
||||
| `#/media/:channel/settings/...` | 影音设置及媒体库管理 |
|
||||
|
||||
书架、书库、书源、设置和影音是独立页面;影音内部再按频道、收藏、历史、队列、
|
||||
搜索、详情和管理功能拆成嵌套路由。分页、筛选和播放器参数放在路由参数或 query 中,
|
||||
页面不会通过单文件里的 pathname 分支来决定渲染哪一组业务页面。
|
||||
`/sources/search`、旧的 `/sources/:sourceId/:pageId` 扩展页形状和
|
||||
`/media`、`/media/music`、`/media/audiobook` 默认入口仍由路由表兼容;
|
||||
新页面应使用当前命名空间和嵌套路由。未知路径回到 `/media/video`。影音内部按频道、
|
||||
收藏、历史、队列、搜索、详情和管理功能拆成嵌套路由,分页、筛选和播放器参数放在
|
||||
路由参数或 query 中。
|
||||
|
||||
Hash 路由适配 WebView 和静态服务器:宿主只需要返回同一个 `index.html`,客户端从
|
||||
`#` 后解析页面,不依赖服务端为每个深层路径配置回退规则。登录是认证边界而不是业务
|
||||
页面;未认证时由 `AuthBoundary` 显示登录表单,认证完成后 React Router 渲染目标地址。
|
||||
|
||||
阅读器是唯一的特殊生命周期边界:`ReaderPage` 创建 `ReaderScreen`,将其舞台
|
||||
通过 `StageHost` 插入 React 树,并在 effect 清理时调用 `dispose()`。页面离开后,
|
||||
reader 的异步请求和分页更新不会再写入已卸载的 DOM;其它页面完全遵循 React 的
|
||||
挂载、更新和卸载语义。
|
||||
### 阅读器与长生命周期资源
|
||||
|
||||
阅读器是唯一的特殊生命周期边界:`ReaderPage` 在 effect 中取得书籍后创建
|
||||
`ReaderScreen`,把它的 `screen.element` 放入 route host,并通过
|
||||
`runtime.registerReader()` 注册进度刷新回调。页面离开时先注销回调,再调用
|
||||
`screen.dispose()` 并清空 host。ReaderScreen 内部的章节加载、分页量测、手势、
|
||||
朗读和 Shadow DOM 仍是命令式 DOM;其 chrome 使用 React,`StageHost` 只负责把
|
||||
ReaderScreen 持有的舞台节点放到 chrome 树中的正确位置。React 不介入书籍正文的布局量测。
|
||||
|
||||
播放器是应用级资源。AppProviders 把 `runtime.player.element` 挂到
|
||||
`document.body`,启动 `PlaybackService` 对 `MediaPlayer` 的监听。路由切换只卸载
|
||||
控制页面,不销毁播放会话;播放器发出 `open-controls` 时由 `AuthenticatedShell`
|
||||
导航到带 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除
|
||||
播放器节点,应用隐藏或离开时统一调用 runtime flush,重新联网时续期会话。
|
||||
|
||||
影音主题只在 `MediaLayout` 挂载期间生效。它创建 `MediaThemeController`,作用域来自
|
||||
`runtime.mediaApi.preferenceScope()`,并只向 `body` 写入带 `--media-theme-` 前缀的
|
||||
变量;系统主题、同页主题事件和存储变化会更新该作用域。离开影音路由时清理变量和监听器,
|
||||
因此不会污染书架、设置或阅读器主题。
|
||||
|
||||
### 只有一份渲染层
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ npm run build --prefix web
|
||||
|
||||
Web tests use both Vitest and Node's test runner. UI reviews use real Chromium; prepare it as described above. Historical test counts are not a substitute for current results.
|
||||
|
||||
For UI changes, run `npm run ui:review --prefix web`; source workflows have `npm run ui:sources --prefix web`. See [UI review (中文)](ui-review/README.md) for tooling and requirements. Reviews generate screenshots and reports; screenshots are ignored by Git by default.
|
||||
For UI changes, run `npm run ui:review --prefix web`; source workflows have `npm run ui:sources --prefix web`. The review scripts live under `web/tools/ui-review/` and write screenshots and reports to the Git-ignored `artifacts/ui-review/` directory. Do not commit one-off screenshots under `docs/`.
|
||||
|
||||
## Build and run
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ npm run build --prefix web
|
||||
|
||||
Web 测试同时包含 Vitest 和 Node 测试。UI 评审使用真实 Chromium;浏览器缺失时按上面的步骤准备环境。不要把历史测试数量当作当前验证结果。
|
||||
|
||||
UI 改动可运行 `npm run ui:review --prefix web`,书源流程可运行 `npm run ui:sources --prefix web`。评审工具及环境说明见 [UI 评审](ui-review/README.md),会生成截图和报告;截图目录默认不纳入 Git。
|
||||
UI 改动可运行 `npm run ui:review --prefix web`,书源流程可运行 `npm run ui:sources --prefix web`。评审脚本位于 `web/tools/ui-review/`,会把截图和报告写入被 Git 忽略的 `artifacts/ui-review/`;不要把一次运行的截图提交到 `docs/`。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
|
||||
@@ -1,121 +1,85 @@
|
||||
# 前端 React 架构重构方案
|
||||
# 前端 React 架构说明
|
||||
|
||||
## 目标
|
||||
本文保留原“前端 React 迁移”文件名以避免已有链接断开,内容描述当前实现约定。生产 Web 入口已经使用 React 19;后续页面和组件应遵循本文及[架构与数据边界](architecture.md)中的边界,不再向旧的 Screen 调度链添加代码。
|
||||
|
||||
将 `web` 从 Preact + 自研 Screen/Router 的混合实现迁移为完整的 React 19 应用。路由、页面生命周期、服务端状态、客户端共享状态和基础交互组件分别由成熟方案负责,业务页面按领域拆分。
|
||||
## 当前技术栈
|
||||
|
||||
第一阶段优先迁移影音模块;第二阶段迁移登录、书架、书库、书源、设置等非阅读页面;最后只保留阅读内容引擎的命令式 DOM 实现,并由 React 页面托管其生命周期。
|
||||
|
||||
## 已确定的技术选择
|
||||
|
||||
| 能力 | 方案 | 责任 |
|
||||
| 能力 | 当前实现 | 责任 |
|
||||
| --- | --- | --- |
|
||||
| UI 运行时 | React 19 + React DOM | 所有页面和组件的唯一 UI 运行时 |
|
||||
| 路由 | React Router 7 `createHashRouter` | Hash URL、嵌套路由、Layout、Outlet、错误边界和权限守卫 |
|
||||
| UI 运行时 | React 19 + React DOM | 页面、组件和生命周期 |
|
||||
| 路由 | React Router 7 `createHashRouter` | Hash URL、嵌套路由、Outlet、参数、错误边界和导航 |
|
||||
| 服务端状态 | `@tanstack/react-query` | 查询缓存、去重、mutation、失效和预取 |
|
||||
| 客户端共享状态 | Zustand | 认证、设置、同步、播放等跨页面状态 |
|
||||
| 基础组件 | Mantine | 对话框、表单、菜单、选择器、通知、加载和管理界面 |
|
||||
| 客户端共享状态 | Zustand | 认证、设置、同步和播放状态 |
|
||||
| 基础组件 | Mantine | 主题、表单、菜单、通知、加载和管理界面 |
|
||||
| 图标 | `lucide-react` | React 图标组件 |
|
||||
| 构建 | Vite + TypeScript | 保持 `base: './'` 和 Android 单 Bundle 约束 |
|
||||
| 构建 | Vite + TypeScript | H5 与 Android WebView 共用的相对资源单 Bundle |
|
||||
|
||||
Hash 路由继续使用,因为同一份构建产物由 H5 服务和 Android WebView 消费,Android 资源地址不支持任意路径 fallback。旧路由兼容不是目标;新路由以 React Router 的标准嵌套路由为准。
|
||||
继续使用 Hash 路由是因为同一份产物同时由 H5 服务和 Android WebView 加载;两个宿主都不能为任意路径提供客户端回退。新的链接应使用 `#/...` 形式。
|
||||
|
||||
## 目录约定
|
||||
## 启动与运行时
|
||||
|
||||
`web/src/main.tsx` 创建 `AppRuntime`,然后调用 `createRoot()` 渲染 `AppProviders` 和 `RouterProvider`。`web/src/app/runtime.ts` 只负责组装共享资源,不选择页面:
|
||||
|
||||
- 根据环境选择 Web 或 Android 平台,创建 `ReaderApi`、`MediaApi`、`MediaPlayer`、`PlaybackService`、`SyncEngine`、`OfflineStore`、`SettingsStore` 和 Query Client;
|
||||
- 恢复本地会话、服务地址、离线作用域和同步状态;
|
||||
- 暴露 flush、登录状态、阅读器注册和设置更新等宿主能力。
|
||||
|
||||
`AppProviders` 提供 `RuntimeContext`、`QueryClientProvider`、`MantineProvider` 和全局通知。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听、写入根主题属性,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。
|
||||
|
||||
## 目录边界
|
||||
|
||||
当前 Web 代码按以下实际目录组织:
|
||||
|
||||
```text
|
||||
web/src/
|
||||
app/
|
||||
App.tsx
|
||||
providers/
|
||||
router/
|
||||
layouts/
|
||||
app/ # AppShell、Provider、运行时和 React Router
|
||||
features/
|
||||
auth/
|
||||
shelf/
|
||||
library/
|
||||
sources/
|
||||
media/
|
||||
pages/
|
||||
components/
|
||||
api/
|
||||
queries/
|
||||
mutations/
|
||||
stores/
|
||||
services/
|
||||
styles/
|
||||
reader/
|
||||
pages/
|
||||
components/
|
||||
services/
|
||||
shared/
|
||||
api/
|
||||
query/
|
||||
stores/
|
||||
ui/
|
||||
hooks/
|
||||
platform/
|
||||
types/
|
||||
auth/pages/ # 登录
|
||||
library/pages/ # 书库浏览和文件管理
|
||||
media/ # 影音 API、页面、组件、查询、变更、服务、状态和样式
|
||||
reader/pages/ # 阅读路由页面
|
||||
settings/pages/ # 用户与服务设置
|
||||
shelf/pages/,queries/ # 书架
|
||||
sources/hooks/,pages/ # 书源及扩展页面
|
||||
shared/query/,stores/,ui/ # 跨领域查询、状态和 UI 边界
|
||||
api/, core/, formats/, net/ # API、平台、格式和网络基础能力
|
||||
render/ # 分页、PDF、文本、朗读等渲染服务
|
||||
store/ # 离线、出版物、设备设置存储
|
||||
ui/ # 阅读器命令式舞台及可复用交互组件
|
||||
styles/ # 应用和阅读器全局样式
|
||||
```
|
||||
|
||||
页面组件只处理页面展示和交互。API 调用通过领域 query/mutation hooks;跨页面状态通过领域 store;页面临时状态留在组件内部。路由页面不得通过布尔字段判断其它页面。
|
||||
页面组件只组合页面展示、查询和交互。API 请求通过领域 API、query 或 mutation 访问;跨页面状态放在 Zustand store;播放器、主题和阅读器等长生命周期行为放在 service。旧的 `web/src/media`、旧 Screen 页面、旧手写生产路由和 `mountUI` 已从生产入口移除。`web/src/app.ts` 与 `app/legacy-compat.ts` 仅保留 Android 诊断和旧测试所需的兼容形状,不负责生产页面调度。
|
||||
|
||||
影音领域的 React 组件、API 类型、跨页面服务和样式统一放在 `features/media` 下;旧的 `src/media` 目录已删除,测试与 Android 诊断也直接引用新目录,没有旧媒体路径转导层。领域内同目录组件使用 `./` 导入,跨职责目录使用 `../api`、`../services`、`../styles` 等明确路径;样式唯一存放在 `styles`,避免组件目录与样式目录维护重复副本。路由入口集中在 `app/router/routes.tsx`。
|
||||
## 路由与兼容
|
||||
|
||||
阅读器的命令式引擎和 Android 宿主仍保留在 `ui`/`core` 边界,由 `features/reader/pages/ReaderPage` 明确托管生命周期。`app.ts` 的诊断兼容接口只提供平台、flush 等宿主能力,不参与生产页面调度。
|
||||
路由定义集中在 `web/src/app/router/routes.tsx`,根级 `AppShell` 先经过 `AuthBoundary`,认证后渲染页面 Outlet。影音由 `MediaLayout` 提供频道上下文、主题生命周期和公共频道入口,具体页面继续按路由拆分。
|
||||
|
||||
## 生命周期边界
|
||||
保留的兼容入口是有意的:
|
||||
|
||||
播放器是应用级服务。`PlaybackService` 管理 audio/video/native bridge、播放队列和进度保存,`playback.store` 暴露可观察状态。离开播放页面只卸载控制界面,不销毁播放会话。
|
||||
- `/settings` 重定向到 `/shelf`,兼容旧书架设置深链接;
|
||||
- `/sources/search` 仍打开书源中心;
|
||||
- `/sources/:sourceId/:pageId` 与新的 `/sources/:sourceId/pages/:pageId` 都可打开扩展页;
|
||||
- `/media`、`/media/music` 和 `/media/audiobook` 重定向到对应频道默认页,未知路径回到 `/media/video`。
|
||||
|
||||
阅读器内容仍由 `ReaderEngine`、分页器、Shadow DOM、朗读和 Android fixed-page host 负责。`ReaderPage` 使用 React effect 创建、更新和销毁引擎;React 不介入书籍内容的布局量测。
|
||||
兼容路由只保证旧链接仍能进入正确的 React 页面;新代码应直接使用当前路由树中的命名空间和嵌套路径。
|
||||
|
||||
## 影音路由边界
|
||||
## 阅读器托管边界
|
||||
|
||||
`MediaLayout` 只提供公共导航、频道上下文和 `<Outlet />`。每个可刷新、可返回的页面有独立组件,包括频道首页、分类、搜索、收藏、历史、队列、详情、元数据、匹配、章节、播放器、歌词、设置、媒体库管理、权限、任务、文件夹和文件。
|
||||
`features/reader/pages/ReaderPage.tsx` 是 React 与阅读器命令式实现之间的边界。页面取得书籍后,在 effect 中创建 `ReaderScreen`,把 `screen.element` 放入 route host,并通过 `runtime.registerReader()` 注册进度刷新回调。清理 effect 时先注销回调,再调用 `screen.dispose()` 并清空 host。
|
||||
|
||||
## 迁移顺序
|
||||
`ReaderScreen` 内部仍负责章节加载、Shadow DOM、分页量测、手势、朗读和阅读工具;React 只托管它的创建、更新和销毁,不介入书籍正文的布局量测。ReaderScreen 的 chrome 使用 React 组件,正文舞台保持命令式 DOM,以确保 `getBoundingClientRect()` 和章节分页的节点生命周期稳定。
|
||||
|
||||
1. React 运行时、Vite 插件、React Router、React Query、Zustand、Mantine 和应用 Provider。
|
||||
2. React 根应用、Hash Router、错误边界和权限边界。
|
||||
3. 影音 Layout、路由树、Query/Mutation 层和目录页面。
|
||||
4. 详情、元数据、章节、媒体库管理、设置和任务页面。
|
||||
5. PlaybackService、播放器页和 MiniPlayer。
|
||||
6. 删除 `MediaScreen`、旧媒体路由分支、旧 `media` 目录和媒体侧 `mountUI` 使用。
|
||||
7. 迁移其它非阅读页面。
|
||||
8. React 化阅读器外围页面,保留 ReaderEngine。
|
||||
9. 删除 Preact、`lucide-preact`、旧 `mountUI` 和旧 Screen 调度链。
|
||||
## 播放器和媒体主题生命周期
|
||||
|
||||
## 验收标准
|
||||
播放器是应用级资源。`AppProviders` 把 `runtime.player.element` 挂到 `document.body`,调用 `runtime.playback.start()` 建立 `PlaybackService` 对 `MediaPlayer` 的监听;路由切换只卸载控制界面,不销毁播放会话。`AuthenticatedShell` 根据当前 URL 控制播放器可见性,并处理播放器发出的 `open-controls` 事件,将用户带到带有 `item` 和 `part` 查询参数的播放器路由。Provider 卸载时停止监听并移除播放器节点。
|
||||
|
||||
- 所有页面由 React Router 管理,`App` 不再手写页面 switch。
|
||||
- `MediaScreen` 删除,旧 `src/media` 目录删除,媒体页面按 route/page/query/mutation 拆分。
|
||||
- React Query 统一管理服务端状态,Zustand 管理客户端共享状态。
|
||||
- 播放器跨路由持续播放,阅读引擎拥有明确的 React 托管生命周期。
|
||||
- Mantine 负责基础交互组件;品牌样式只做必要覆盖,不强行改写组件内部实现。
|
||||
- 保持 Android WebView 单 Bundle、相对资源和 PWA 能力。
|
||||
- 现有媒体、认证、同步、播放器、阅读器关键回归测试通过。
|
||||
- 新增页面不需要修改一个巨型 Screen 文件。
|
||||
影音主题由 `MediaLayout` 创建 `MediaThemeController`,作用域来自 `runtime.mediaApi.preferenceScope()`。控制器只向 `body` 写入带 `--media-theme-` 前缀的变量,响应系统主题、同页主题事件和存储变化;离开影音路由时清理变量和监听器,因此不会污染书架或阅读器主题。
|
||||
|
||||
## 当前实施状态
|
||||
|
||||
已完成第一轮可运行切换:
|
||||
|
||||
- `main.tsx` 使用 `createRoot`、`AppProviders` 和 `RouterProvider`,应用启动不再经过旧 `App.start()`。
|
||||
- 路由已改为 `createHashRouter`,影音的频道、分类、搜索、收藏、历史、队列、详情和设置入口拥有独立 React 路由。
|
||||
- 媒体列表和详情页面通过 React Query 查询层访问 `MediaApi`,基础交互使用 Mantine。
|
||||
- 书架、书库、书源和设置已建立独立 React 页面与路由,旧 Screen 不再参与新的入口调度。
|
||||
- `MediaPlayer` 已由 `PlaybackService` 管理并在 Provider 中启动,跨路由播放状态通过 Zustand 暴露,播放器 DOM 不随页面路由销毁。
|
||||
- 已用浏览器验证开发服务器能够启动并显示 React 登录页面;`npm run typecheck` 与 `npm run build` 通过。
|
||||
|
||||
当前迁移已完成生产入口切换和影音目录收敛。影音管理、播放器控制、登录、书架、书库、书源、设置和阅读路由均由 React Router 管理;旧 `MediaScreen`、旧媒体路由、旧 `mountUI`、Preact 适配层、旧 Screen 页面和 `src/media` 目录已删除。阅读内容引擎仍作为 `ReaderPage` 的命令式服务边界存在,并由 React effect 负责创建、刷新和销毁。
|
||||
|
||||
最终验证结果:
|
||||
|
||||
- `npm run typecheck` 通过。
|
||||
- `npm run build` 通过;Vite 完成约 2633 个模块构建,产物仍为 Android WebView 可加载的相对资源单 Bundle。仅保留大 chunk 的性能提示,未阻断构建。
|
||||
- `npm test` 通过:97 个 Vitest 测试文件、811 个测试全部通过;Node 测试 29 项全部通过。
|
||||
- `npm run ui:review:only` 通过:40 张 React 页面截图,详情、视频播放、历史和队列页面均完成视觉回归。
|
||||
- `git diff --check` 通过;输出的换行符提示属于工作区 CRLF 转换提示,不是 whitespace error。
|
||||
|
||||
至此生产入口已经完成 React 化。后续新增页面应继续按 `app / features / shared` 边界实现,由 React Router 接入路由,不再向旧 Screen 调度链添加逻辑。
|
||||
## 后续改动约定
|
||||
|
||||
- 新页面放入对应的 `features/<domain>/pages`,通过 `routes.tsx` 接入;
|
||||
- 领域组件、查询、变更、服务和样式留在各自 feature 内,跨领域依赖经过 `shared` 或 `app` 边界;
|
||||
- 需要长生命周期的资源必须由 Provider 或明确的 service 管理,并在清理阶段释放;
|
||||
- 阅读器正文继续通过 `ReaderScreen` 托管,不把命令式舞台改写为普通 React 子树;
|
||||
- 新链接使用 Hash 路由和当前嵌套路由,不新增旧 Screen 或手写 pathname 分支。
|
||||
@@ -29,4 +29,4 @@ Web 音频使用浏览器媒体能力,视频采用 DPlayer;Android 使用 Me
|
||||
- 服务端:[media 模块](../server/src/media/)、[HTTP 路由](../server/src/http/routes/media.ts)。
|
||||
- 前端:[media feature 模块](../web/src/features/media/)、[React 页面路由](../web/src/app/router/routes.tsx)。
|
||||
- [使用说明](media-user-guide.zh-CN.md)、[导航约定](media-navigation.md)、[验证方式](media-validation.md)、[性能测量](media-catalog-performance.md)。
|
||||
- [冻结的 v2 原型](prototypes/media/v2-review/index.html)是布局参考;实际功能与地址以源码及当前说明为准。
|
||||
- [冻结的 v2 原型](prototypes/media/v2-review/index.html)是布局参考;实际功能、地址和主题生命周期以源码及当前说明为准。浏览器评审截图和报告统一写入被忽略的 `artifacts/media`,不再提交到 `docs/`。
|
||||
@@ -31,6 +31,6 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真
|
||||
| 文件夹、文件 | #/media/:channel/folders/:libraryId?path=…&offset=…;文件继续使用该路由并追加 asset=… |
|
||||
| 演播者、演播作品 | #/media/audiobook/narrators/:narrator,以及 /media/audiobook/narrators/:narrator/works/:workId |
|
||||
|
||||
旧路由不承担兼容承诺。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
|
||||
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;未知媒体路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
|
||||
|
||||
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型内的示例路由只用于设计演示。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
先构建 web,再从 web 目录运行:
|
||||
|
||||
- `npm run ui:review:only`:启动真实服务端媒体夹具,使用生产 React bundle 在 390/1120px 检查书架、书库、影音频道、详情、收藏、播放器、设置和权限页面;同时覆盖搜索、收藏筛选分页、收藏写回、播放会话恢复、媒体库增改和普通用户权限边界。报告写入忽略的 `artifacts/ui-review/react-app/verification.json`,要求 `passed=true`、38 张截图、无页面错误和横向溢出。
|
||||
- `npm run ui:review:only`:启动真实服务端媒体夹具,使用生产 React bundle 在 390/1120px 检查书架、书库、影音频道、详情、收藏、播放器、设置和权限页面;同时覆盖搜索、收藏筛选分页、收藏写回、播放会话恢复、媒体库增改和普通用户权限边界。报告写入忽略的 `artifacts/ui-review/react-app/verification.json`,以报告中的 `passed`、`screenshots`、页面错误和横向溢出结果为准,不把历史截图数量写入文档。
|
||||
|
||||
- node tools/media-desktop-openlist-review.cjs:桌面分类、选择器、收藏、OpenList 表单及视口边界。
|
||||
- node tools/media-openlist-e2e.cjs:生产页面、真实 Reader 服务、临时数据库,以及本机模拟 OpenList 的建库、扫描、播放和凭据维护闭环。
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# P1 实施与验证记录(2026-09-23)
|
||||
|
||||
本次实现原需求分析 D/E/F 的阅读工具主流程,并补充万本索引的查询基线。改动保留在工作区,未提交、未部署。
|
||||
|
||||
## 已实现
|
||||
|
||||
| 能力 | 交付行为 |
|
||||
| --- | --- |
|
||||
| 书内检索 | EPUB/TXT逐章搜索、摘要、200 条上限、取消、命中高亮、返回原阅读位置;不对图片或扫描 PDF 做 OCR |
|
||||
| 批注 | 正文选区高亮、批注、书签、编辑、删除、回正文;沿用现有离线同步和墓碑机制;修复同 ID 笔记跨账号覆盖 |
|
||||
| EPUB 目录 | Web/服务端解析有序 nav/NCX 树,保留层级、fragment、spine,支持折叠和章内定位 |
|
||||
| 个人纠错 | 精确文字替换、选区过滤、移除及撤销;按账号/书籍保存在 DATA_DIR/reader.db,版本冲突返回 409;不改原书 |
|
||||
| TXT 目录 | 中文、英文、方括号模板和自定义标题前缀;预览后应用,规则持久化并可撤销 |
|
||||
| 设备离线 | TXT范围下载;EPUB/PDF/CBZ 整文件下载,保留 EPUB 内部资源;暂停、继续、取消;失败可重试并跳过完成资源 |
|
||||
| 缓存管理 | 服务地址与账号隔离;默认 256 MiB 配额、实际资源字节占用、按书清理;配额写入串行,失败回滚旧资源 |
|
||||
| 朗读诊断 | 当前引擎试听;HTTP 实际合成检测,分别报告配置、鉴权、响应体超时、非音频、空音频;停止和关面板释放试听 |
|
||||
| 大书库基线 | 10,000 条索引的首尾分页、搜索、增量同步验证,单次查询按页返回 |
|
||||
|
||||
主要实现位于 web/src/ui/reading-tools.tsx、text-anchor.ts、reading-overrides.ts、core/offline-download.ts、store/publications.ts,以及 server/src/services/reading-overrides.ts 和对应路由。
|
||||
|
||||
## 验证
|
||||
|
||||
- Web 全量 Vitest:40 个测试文件、585 项通过。此后新增 1 项下载重试/自定义 TXT 测试,相关 34 项定向回归通过。
|
||||
- Web Node 测试:29 项通过。
|
||||
- 服务端全量:338 项,336 通过、2 项 Windows 环境跳过;最后的覆盖层参数校验和万本测试再次通过。
|
||||
- 两端 TypeScript 检查、生产构建通过;git diff --check 通过。
|
||||
- 真实 Chromium + 真实 HTTP/SQLite + 生产 Web:320/390/1280 宽度无横向溢出;搜索高亮、返回、三级目录折叠/展开、选区批注、整 EPUB 缓存后阻断所有 API 并重开、纠错刷新保留/撤销、TXT 预览应用通过。
|
||||
- 浏览器验证脚本:web/tools/ui-review/p1-reading.mjs。在 web 运行 node --import tsx tools/ui-review/p1-reading.mjs;截图在 docs/ui-review/p1/。
|
||||
- 万本基线:内存 SQLite,10,000 条书籍/文件/书架记录;本机独立运行三次查询合计约 91ms。并发测试受负载影响,数值只用于基线,不作为 NAS/机械盘性能承诺。
|
||||
|
||||
## 明确边界
|
||||
|
||||
- 本轮完成的是上述可用流程;真实 iOS/Android、HTTPS 反代、后台音频和跨真机恢复尚待实机验收。
|
||||
- 未做磁盘上万本真实书籍的全量/增量扫描压测;内存索引基线不能替代该验收。
|
||||
- TXT 自定义规则是有界标题前缀匹配,不支持任意正则或跨书自动套用的规则库;变更章节结构后从首章打开,旧定位可能失效。
|
||||
- 纠错规则联网保存,已缓存规则离线应用;其他设备下次打开书籍拉取,不通过进度同步接口实时推送。空替换只过滤选区,未实现全书正则过滤。
|
||||
- 下载任务关闭面板即暂停。整文件下载重试从头请求;章节下载按已完成资源续传。图片目录不支持整书预下载。
|
||||
- 配额统计是新账号作用域下的正文/整文件缓存,未包含封面、服务端缓存和旧版本非账号隔离缓存;不自动淘汰。
|
||||
- 浏览器缓存可能被系统回收;未缓存的章节仍需联网。正文版本变化或目录重排后的旧笔记不会强行匹配到不确定文本。
|
||||
- Docker 引擎未启动,本轮未做容器和实际部署验收。
|
||||
@@ -1,9 +1,7 @@
|
||||
# 影音设计参考
|
||||
|
||||
本目录保留当前仍使用的设计基准和最近一轮参考图集。早期原型、反复覆盖的截图、失效的构建哈希和逐轮进度记录已移出仓库。
|
||||
本目录只保留当前仍使用的设计基准。早期原型、反复覆盖的截图、失效的构建哈希和逐轮进度记录已移出仓库。
|
||||
|
||||
- [冻结 v2 高保真原型](v2-review/index.html):已采用的布局基准,包含桌面和手机、主题及状态。示例内容和旧示例路由不代表生产数据或当前路由协议。
|
||||
- [桌面分类、选择器、收藏和 OpenList 表单](desktop-openlist-review/index.html):最近一轮实际页面与参考对照。
|
||||
- [OpenList 浏览器闭环](openlist-e2e-review/index.html):使用本机模拟上游的生产页面流程,未连接用户真实实例。
|
||||
|
||||
后续验收结果生成到 artifacts/media(已忽略),需要长期保留的截图应人工挑选;不要提交整批重复截图。运行方式见[验证说明](../../media-validation.md)。
|
||||
后续验收结果生成到被 Git 忽略的 `artifacts/media`,需要长期保留的截图应人工挑选;不要提交整批重复截图。运行方式见[验证说明](../../media-validation.md)。
|
||||
@@ -1,33 +0,0 @@
|
||||
# 桌面分类、选择器、收藏与 OpenList 复核
|
||||
|
||||
[布局与下拉截图](index.html) · [OpenList 浏览器闭环](../openlist-e2e-review/index.html) · [OpenList 配置说明](../../../media-openlist.md)
|
||||
|
||||
## 本次修正
|
||||
|
||||
- 桌面分类栏原来的固定父容器容纳不了 tab 的高度与外边距,造成文字、下划线和分隔线错位。改为随内容计算父栏高度,tab 保持 44px,桌面顶部间距 5px、底部间距 16px。
|
||||
- 影音选择器改为统一主题弹层,覆盖媒体库、版本、季、排序、权限、播放偏好等;支持选中标记、键盘操作、外部点击和 Escape 关闭,靠近视口底部向上展开。Plyr 自带控件不在替换范围。
|
||||
- 收藏统一到 `#/media/favorites`,旧频道收藏链接继续可用。类型筛选放在弹窗内,`scope` 与分页随 URL 恢复。列表按 v2 使用最大 850px 的居中内容区、紧凑行、计数和筛选入口。
|
||||
- 新建媒体库增加 OpenList。输入服务地址、远端目录,以及可选令牌和目录密码;创建前验证连接,随后扫描。编辑页面只显示凭据是否已配置,可更新或显式清除,留空保留。
|
||||
|
||||
## 视觉核对
|
||||
|
||||
生产构建在 390、1120、1308px 下生成 20 张截图。人工检查了桌面分类栏、浅色与深色下拉、收藏列表、新建 OpenList 表单和窄屏向上展开的下拉。图集中分类和收藏附有冻结 v2 原型对照;原型本身未修改。原型中的播放进度、数量和资料仅为示例,实现按可用数据展示。收藏的规范路由按本次需求更新,冻结原型内旧路由文案不代表本次实现。
|
||||
|
||||
结构断言验证 tab 高度、按钮边界、下划线、隐藏滚动条、列表居中宽度、菜单视口边界及页面无横向溢出。交互验证旧路由规范化、收藏筛选刷新恢复和选择器关闭。结构断言用于补充图片审查。
|
||||
|
||||
## 功能验证与边界
|
||||
|
||||
- 前端全量 Vitest 运行通过;新增选择器交互与收藏行为另有定向回归,生产构建通过。
|
||||
- 后端 OpenList、本地存储、扫描、权限、元数据、迁移和启动隔离相关回归通过。依赖 FFmpeg 的 5 项真实内嵌资源测试因环境未配置而跳过,未记为通过。
|
||||
- 浏览器闭环使用生产页面、真实 Reader 接口、扫描器与临时 SQLite 数据库;OpenList 上游是本机同契约模拟服务。验证了创建、扫描、实际解码播放、精确 Range 字节读取、凭据保留与轮换,以及下载请求不转发 API 令牌。
|
||||
- 未连接用户的真实 OpenList 或具体网盘驱动;未进行 Android 真机测试。OpenList 当前使用服务端代理播放,远端文件暂不读取内嵌标签、封面和章节,支持外置资料;不包含转码。
|
||||
- 阅读页面、书架设置与默认阅读入口保持原有行为。
|
||||
|
||||
复跑命令(从 `web` 目录运行,先完成 `npm run build`):
|
||||
|
||||
```powershell
|
||||
node tools/media-desktop-openlist-review.cjs
|
||||
node tools/media-openlist-e2e.cjs
|
||||
```
|
||||
|
||||
可通过 `PROTOTYPE_CHROMIUM` 指定本机 Chromium 路径。所有验收服务和数据均为临时实例。
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,217 +0,0 @@
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"key": "tabs-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "tab-geometry-390",
|
||||
"top": 56,
|
||||
"bottom": 100,
|
||||
"height": 44,
|
||||
"buttonTop": 56,
|
||||
"buttonBottom": 100,
|
||||
"scrollbar": "none",
|
||||
"border": "2px"
|
||||
},
|
||||
{
|
||||
"key": "library-select-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorites-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "openlist-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "access-select-390",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "tabs-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "tab-geometry-1120",
|
||||
"top": 73,
|
||||
"bottom": 117,
|
||||
"height": 44,
|
||||
"buttonTop": 73,
|
||||
"buttonBottom": 117,
|
||||
"scrollbar": "none",
|
||||
"border": "2px"
|
||||
},
|
||||
{
|
||||
"key": "library-select-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorites-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "openlist-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "access-select-1120",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "tabs-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "tab-geometry-1308",
|
||||
"top": 73,
|
||||
"bottom": 117,
|
||||
"height": 44,
|
||||
"buttonTop": 73,
|
||||
"buttonBottom": 117,
|
||||
"scrollbar": "none",
|
||||
"border": "2px"
|
||||
},
|
||||
{
|
||||
"key": "library-select-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorites-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "openlist-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "access-select-1308",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "video-desktop",
|
||||
"overflow": 0
|
||||
},
|
||||
{
|
||||
"key": "select-dark",
|
||||
"overflow": 0
|
||||
}
|
||||
],
|
||||
"captures": [
|
||||
{
|
||||
"key": "tabs-390",
|
||||
"title": "频道分类 · 390",
|
||||
"reference": true
|
||||
},
|
||||
{
|
||||
"key": "library-select-390",
|
||||
"title": "媒体库下拉 · 390",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "favorites-390",
|
||||
"title": "收藏列表 · 390",
|
||||
"reference": true
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-390",
|
||||
"title": "收藏类型筛选 · 390",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "openlist-390",
|
||||
"title": "新建 OpenList 媒体库 · 390",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "access-select-390",
|
||||
"title": "表单下拉 · 390",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "tabs-1120",
|
||||
"title": "频道分类 · 1120",
|
||||
"reference": true
|
||||
},
|
||||
{
|
||||
"key": "library-select-1120",
|
||||
"title": "媒体库下拉 · 1120",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "favorites-1120",
|
||||
"title": "收藏列表 · 1120",
|
||||
"reference": true
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-1120",
|
||||
"title": "收藏类型筛选 · 1120",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "openlist-1120",
|
||||
"title": "新建 OpenList 媒体库 · 1120",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "access-select-1120",
|
||||
"title": "表单下拉 · 1120",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "tabs-1308",
|
||||
"title": "频道分类 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "library-select-1308",
|
||||
"title": "媒体库下拉 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "favorites-1308",
|
||||
"title": "收藏列表 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "favorite-filter-1308",
|
||||
"title": "收藏类型筛选 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "openlist-1308",
|
||||
"title": "新建 OpenList 媒体库 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "access-select-1308",
|
||||
"title": "表单下拉 · 1308",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "video-desktop",
|
||||
"title": "影视频道顶部分类",
|
||||
"reference": false
|
||||
},
|
||||
{
|
||||
"key": "select-dark",
|
||||
"title": "深海主题下拉",
|
||||
"reference": false
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>桌面分类、收藏与 OpenList 验收</title><style>body{margin:24px auto;padding:0 24px;max-width:1400px;font:14px/1.6 system-ui;background:#f8f9f5;color:#202b24}.pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px}img{max-width:100%;border:1px solid #dfe5d9}figure{margin:0}section{margin:32px 0}figcaption{margin-bottom:8px;color:#66715f}</style><h1>桌面分类、收藏与 OpenList 验收</h1><p>使用生产构建。对照冻结 v2 原型的分类栏与收藏布局;下拉和 OpenList 接入表单为本次新增。展示数据是临时验收数据,未写入用户媒体库。OpenList 接入另由接口集成测试验证,页面中地址为示例。</p><section><h2>频道分类 · 390</h2><div class="pair"><figure><figcaption>冻结的 v2 原型</figcaption><img src="tabs-390-reference.png"></figure><figure><figcaption>本次实现</figcaption><img src="tabs-390-actual.png"></figure></div></section><section><h2>媒体库下拉 · 390</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="library-select-390-actual.png"></figure></div></section><section><h2>收藏列表 · 390</h2><div class="pair"><figure><figcaption>冻结的 v2 原型</figcaption><img src="favorites-390-reference.png"></figure><figure><figcaption>本次实现</figcaption><img src="favorites-390-actual.png"></figure></div></section><section><h2>收藏类型筛选 · 390</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="favorite-filter-390-actual.png"></figure></div></section><section><h2>新建 OpenList 媒体库 · 390</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="openlist-390-actual.png"></figure></div></section><section><h2>表单下拉 · 390</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="access-select-390-actual.png"></figure></div></section><section><h2>频道分类 · 1120</h2><div class="pair"><figure><figcaption>冻结的 v2 原型</figcaption><img src="tabs-1120-reference.png"></figure><figure><figcaption>本次实现</figcaption><img src="tabs-1120-actual.png"></figure></div></section><section><h2>媒体库下拉 · 1120</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="library-select-1120-actual.png"></figure></div></section><section><h2>收藏列表 · 1120</h2><div class="pair"><figure><figcaption>冻结的 v2 原型</figcaption><img src="favorites-1120-reference.png"></figure><figure><figcaption>本次实现</figcaption><img src="favorites-1120-actual.png"></figure></div></section><section><h2>收藏类型筛选 · 1120</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="favorite-filter-1120-actual.png"></figure></div></section><section><h2>新建 OpenList 媒体库 · 1120</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="openlist-1120-actual.png"></figure></div></section><section><h2>表单下拉 · 1120</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="access-select-1120-actual.png"></figure></div></section><section><h2>频道分类 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="tabs-1308-actual.png"></figure></div></section><section><h2>媒体库下拉 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="library-select-1308-actual.png"></figure></div></section><section><h2>收藏列表 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="favorites-1308-actual.png"></figure></div></section><section><h2>收藏类型筛选 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="favorite-filter-1308-actual.png"></figure></div></section><section><h2>新建 OpenList 媒体库 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="openlist-1308-actual.png"></figure></div></section><section><h2>表单下拉 · 1308</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="access-select-1308-actual.png"></figure></div></section><section><h2>影视频道顶部分类</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="video-desktop-actual.png"></figure></div></section><section><h2>深海主题下拉</h2><div class="pair"><figure><figcaption>本次实现</figcaption><img src="select-dark-actual.png"></figure></div></section></html>
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"date": "2026-09-27T09:10:13.223Z",
|
||||
"scope": "生产构建页面及真实 Reader HTTP 接口、数据库、扫描与播放服务;OpenList 上游由本机模拟,未使用 page.route 模拟业务接口。",
|
||||
"checks": [
|
||||
"生产页面表单向真实 Reader 接口提交 OpenList 连接、音乐类型、访问范围和防重复创建的请求标识。",
|
||||
"真实扫描任务完成并读取远程 NFO;服务地址和目录按规范保存,配置接口只返回凭据是否已配置,不回显令牌或密码。",
|
||||
"Chromium 通过 Reader 代理成功解码并播放生成的 WAV;指定范围读取精确返回 20 字节、206 状态和正确的 Content-Range。",
|
||||
"编辑页不回显凭据;改名时保留原令牌与目录密码,单独更新令牌后保留目录密码,后续真实范围读取继续成功。",
|
||||
"OpenList 授权头没有转发给原始媒体下载地址;浏览器没有页面运行错误。"
|
||||
],
|
||||
"screenshots": [
|
||||
"01-create-openlist",
|
||||
"02-scanned-track",
|
||||
"03-remote-audio-playing",
|
||||
"04-redacted-edit"
|
||||
],
|
||||
"upstreamCalls": 16,
|
||||
"upstreamRanges": [
|
||||
"bytes=0-352843",
|
||||
"bytes=44-63",
|
||||
"bytes=64-83"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><meta charset="utf-8"><title>OpenList 浏览器验收</title><style>body{font:15px system-ui;background:#f8f9f5;color:#202b24;padding:24px;max-width:1200px;margin:auto}img{max-width:100%;border:1px solid #dfe5d9}figure{margin:24px 0}li{margin:8px 0}</style><h1>OpenList 创建、扫描与播放验收</h1><p>生产构建页面、真实 Reader 服务与临时数据库。OpenList 上游为本机模拟服务;音频为程序生成的 8 秒 WAV。未拦截或模拟 Reader 业务 API。</p><ul><li>生产页面表单向真实 Reader 接口提交 OpenList 连接、音乐类型、访问范围和防重复创建的请求标识。</li><li>真实扫描任务完成并读取远程 NFO;服务地址和目录按规范保存,配置接口只返回凭据是否已配置,不回显令牌或密码。</li><li>Chromium 通过 Reader 代理成功解码并播放生成的 WAV;指定范围读取精确返回 20 字节、206 状态和正确的 Content-Range。</li><li>编辑页不回显凭据;改名时保留原令牌与目录密码,单独更新令牌后保留目录密码,后续真实范围读取继续成功。</li><li>OpenList 授权头没有转发给原始媒体下载地址;浏览器没有页面运行错误。</li></ul><figure><figcaption>01-create-openlist</figcaption><img src="01-create-openlist.png"></figure><figure><figcaption>02-scanned-track</figcaption><img src="02-scanned-track.png"></figure><figure><figcaption>03-remote-audio-playing</figcaption><img src="03-remote-audio-playing.png"></figure><figure><figcaption>04-redacted-edit</figcaption><img src="04-redacted-edit.png"></figure>
|
||||
@@ -12,7 +12,7 @@
|
||||
.app .seek::-webkit-slider-runnable-track{height:4px;border-radius:4px;background:linear-gradient(to right,var(--accent) 38%,var(--line) 38%)}.app .seek::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);margin-top:-4px;border:0}.app .seek::-moz-range-track{height:4px;border:0;border-radius:4px;background:var(--line)}.app .seek::-moz-range-progress{height:4px;background:var(--accent)}.app .seek::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);border:0}.app .plyr-demo{--accent:#c0d4a6;--line:#778279}.app .video-layout.film-layout{display:block;max-width:960px;margin:auto}@container(min-width:700px){.app .film-layout .video-info{max-width:900px;margin:auto}.app .film-layout .video-tool-row{max-width:440px}}
|
||||
</style></head><body>
|
||||
<header class="review-head"><div><strong>reader. <span style="font-weight:400;color:#718067">影音设计评审 / v2</span></strong><small>待确认方案 · 全部为示例内容与模拟交互 · 未修改业务页面</small></div><div class="review-actions"><button id="directory-toggle">页面目录</button><label>设备 <select id="device"><option value="390">手机 · 390</option><option value="360">窄屏 · 360</option><option value="535">大屏手机 · 535</option><option value="1120">桌面 · 1120</option></select></label><label>主题 <select id="review-theme"><option value="forest">原野</option><option value="sand">暖砂</option><option value="blue">雾蓝</option><option value="rose">蔷薇</option><option value="dark">深海</option><option value="graphite">石墨</option></select></label></div></header>
|
||||
<div class="workspace"><aside class="directory" id="directory"><nav id="page-list" aria-label="原型页面目录"></nav></aside><main class="stage"><div class="stage-info"><div><h1 id="review-title"></h1><p id="review-note"></p></div><span class="status">设计提案 · 等待确认</span></div><div class="frame" id="frame"><div class="app" id="app" data-theme="forest"></div></div><p class="route-note" id="route-note"></p><p class="review-foot"><a href="audit.md">审查报告与优化优先级</a><a href="gallery.html">截图总览</a><a href="../index.html">历史原型</a>原型不连接服务、不读取账号、不播放真实媒体。阅读及书架设置沿用现有界面。</p></main></div>
|
||||
<div class="workspace"><aside class="directory" id="directory"><nav id="page-list" aria-label="原型页面目录"></nav></aside><main class="stage"><div class="stage-info"><div><h1 id="review-title"></h1><p id="review-note"></p></div><span class="status">冻结设计基准</span></div><div class="frame" id="frame"><div class="app" id="app" data-theme="forest"></div></div><p class="route-note" id="route-note"></p><p class="review-foot"><a href="../../../media-validation.md">验证方式</a>原型不连接服务、不读取账号、不播放真实媒体。阅读及书架设置沿用现有界面。</p></main></div>
|
||||
<script>
|
||||
(()=>{'use strict';
|
||||
const app=document.querySelector('#app'),frame=document.querySelector('#frame');
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# UI 评审截图
|
||||
|
||||
由 `npm run ui:review` 生成:真实 Chromium(390×844,2x)加载**生产产物**,
|
||||
对着一个替身 API 逐屏截图并测量。
|
||||
|
||||
| 截图 | 场景 | 看什么 |
|
||||
| --- | --- | --- |
|
||||
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
|
||||
| `02-reader.png` | 阅读页 | 顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口 |
|
||||
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 工具栏一起收起,正文保持原位置,底部只留书名、页码与进度 |
|
||||
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
|
||||
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
|
||||
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
|
||||
| `07-reader-sepia.png` | 阅读页 · 米黄 | 主题切换后的同一页 |
|
||||
| `08-reader-dark.png` | 阅读页 · 夜间 | 暗色下的正文与工具栏 |
|
||||
| `09-panel-txt.png` | TXT · 正文排版 | 纯文本专属的缩进/段间距/编码三行,且面板仍是半屏 |
|
||||
| `10-shelf-paged.png` | 书架 · 第二页 | 没有「继续阅读」横排;默认按最近阅读排序(第一个 chip 是填充态);分页器在封面下方;书库与书源入口在标题下方 |
|
||||
| `11-library.png` | 书库 · 浏览 | 搜索框与封面网格;「加入书架」只画在不在书架的书上,管理员可进入文件管理;没有上传或书架设置入口 |
|
||||
| `12-library-files.png` | 书库 · 文件管理 | 管理员文件页:面包屑与文件行、书籍状态徽章、上传与新建;返回按钮在子目录回上级,在根目录回书库浏览页 |
|
||||
| `17-shelf-unshelve.png` | 书架 · 从书架拿掉 | 封面右上角的「⋯」打开后的动作菜单,以及「从书架拿掉」的确认框:写清只是从书架拿掉、磁盘上的文件不动 |
|
||||
| `15-library-preview-shelve.png` | 书库 · 浏览 · 上架 | 点了某本书的「加入书架」之后:报告、目录重读,以及角标消失的那一本 |
|
||||
| `16-library-search.png` | 书库 · 浏览 · 搜索 | 商城式布局的搜索框:输入之后 URL 带上查询、网格只剩匹配的书,字段两边的留白让控件不贴着封面 |
|
||||
| `13-reader-paged-scrub.png` | 阅读页 · 滑杆拖到本章第 3 页 | 分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章 |
|
||||
| `14-reader-epub-image.png` | EPUB · 插图 | 章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内 |
|
||||
|
||||
## 自动测量
|
||||
|
||||
| 检查 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 手机阅读交互回归 | ✅ | reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏 |
|
||||
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求) |
|
||||
| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
|
||||
| EPUB: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
|
||||
|
||||
插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。" |
|
||||
| EPUB: 比页面宽的插图被收进栏内 | ✅ | 出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2) |
|
||||
| EPUB: 收窄插图没有改变它的比例 | ✅ | 原图 8.750 / 画出来 8.752 |
|
||||
| EPUB: 宽插图没有让页面横向溢出 | ✅ | document 溢出 0px / 阅读面溢出 0px |
|
||||
| 搜索框: 两边都从封面列内缩,且左右相等 | ✅ | 左 14px / 右 14px(字段 0 则为贴着封面) |
|
||||
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 173.0 / 输入框中线 173.0(偏差 -0.0px,字形 16.796875px,框高 48px) |
|
||||
| 书架: 第一个排序是「最近阅读」且默认选中 | ✅ | chips=最近阅读* / 最近入库 / 书名 / 作者 |
|
||||
| 书架: 「最近入库」仍然在排序里 | ✅ | chips=最近阅读 / 最近入库 / 书名 / 作者 |
|
||||
| 书架: 排在最前的是最近读过的那本 | ✅ | 前两本:剑来 第1卷 / 剑来 第2卷 |
|
||||
| 书架: 没有「继续阅读」横排 | ✅ | continue-row=false 卡片 0 张 |
|
||||
| 书架: 每张封面有一个「⋯」操作入口,按得着也看得见 | ✅ | 28.0×28.0 在卡片内=true / 开书区覆盖整卡=true / 静止不透明度 0.55 |
|
||||
| 书库: 浏览页是封面网格,没有文件行 | ✅ | 封面 60 张 / 文件行 0 行 |
|
||||
| 书库: 浏览页有搜索框(商城式布局) | ✅ | 搜索框=true label="搜索书库" |
|
||||
| 书库: 搜索框两边留白,不贴着封面 | ✅ | 字段左 12px / 右 12px,封面比字段再内缩 14px |
|
||||
| 书库: 浏览页的网格铺满窗口,而不是被书架的宽度上限截住 | ✅ | 网格 390px / 窗口 390px,封面占 30 行 |
|
||||
| 书库: 浏览页提供文件管理入口,不放上传、新建或书架设置 | ✅ | 头部控件=返回 / 文件管理 |
|
||||
| 书库: 浏览页只给不在书架的书画「加入书架」,且不是全给 | ✅ | 角标 1 个 / 卡片 60 张,提示="这一页有 1 本还不在书架上,点封面上的按钮可以加进去" |
|
||||
| 书库: 输入搜索词后 URL 与网格一起变,只剩匹配的书 | ✅ | 前 60 张 → 后 2 张(剑来 第1卷 / 第一卷 插图),hash=#/library?q=%E7%AC%AC1%E5%8D%B7 |
|
||||
| 书库: 搜索词进 URL,成为可以发给别人的链接 | ✅ | hash=#/library?q=%E7%AC%AC1%E5%8D%B7 字段="第1卷" |
|
||||
| 书库: 文件管理页画的是文件行,没有封面网格 | ✅ | view=files 行 4 / 封面 0,hash=#/library/files |
|
||||
| 书库: 文件管理页有上传/新建,没有搜索框 | ✅ | 右上角=返回 / 上传书籍 / 新建文件夹 / 书库,搜索框=false |
|
||||
| 书库: 文件管理页提供返回按钮,不使用浏览页签 | ✅ | 头部控件=返回 / 上传书籍 / 新建文件夹 / 书库 |
|
||||
| 书库: 文件管理页没有「下架 / 从书架拿掉 / 加入书架」任何一处 | ✅ | 页面文本命中=无 |
|
||||
| 书库: 文件管理页仍然标出不在书架的书(徽章不是控件) | ✅ | 徽章=true |
|
||||
| 书库: 从文件管理回到浏览是换页,URL 也跟着换 | ✅ | 浏览=true 文件=false hash=#/library 封面 60 张 |
|
||||
| 书架标题栏: 书架设置图标可见,且不超出按钮 | ✅ | 书架设置 · 适中: 18.4×18.4 in 44 box, inside=true |
|
||||
| 书架标题栏: 图标不比标题大(同一行的视觉重量) | ✅ | 图标 18.4 vs 标题 28 |
|
||||
|
||||
全部通过。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 书架与书库样式优化
|
||||
|
||||
日期:2026-09-21。下文记录本轮最终行为。
|
||||
|
||||
- 标题与辅助文字统一;书架提供“书库 / 书源”,书库仅向管理员提供“文件管理”。书库浏览页没有上传输入框、上传入口和书架设置按钮。子目录的面包屑单列显示。
|
||||
- 搜索框统一圆角与高度;空书架隐藏排序栏,刷新移至标题栏。
|
||||
- 空状态统一为图标、标题、说明和主操作卡片。空书架可去搜书或打开书库;空书库引导管理员前往文件管理,普通成员只显示等待管理员添加书籍的提示。存在未识别文件时管理员可查看文件管理。空库不再显示单独的“0 本”状态栏。
|
||||
- 上传与新建文件夹仅在管理员文件管理页出现,只读目录隐藏这两个操作。文件行将名称与大小、时间、状态分两行显示,长名称最多显示两行;点击操作菜单不会再触发行本身的打开动作。
|
||||
- 文件管理返回:子目录回上一级,根目录回书库浏览页;刷新和直达链接也遵循此规则。书架设置 sheet 固定在视口底部,关闭按钮靠右且点击区域为 44px。
|
||||
- 上传 POST 和写入探测 GET 接口均验证管理员权限;普通成员直接调用返回 403,测试确认拒绝后目录未发生变化。
|
||||
- 保留搜索、排序、目录导航和有书时的封面网格。
|
||||
|
||||
验证:Web 构建、服务端类型检查;相关 Web 组件和路由测试,及服务端上传测试通过。生产 bundle 在 Chromium 中检查 320/390/1280px 空状态、有书、子目录、无搜索结果、只读和深色主题;验证书源、书库、文件管理上传选择器、目录返回、成员直达文件管理的拦截、设置 sheet 关闭按钮位置,以及页面和导航无横向溢出。
|
||||
|
||||
浏览器脚本使用可控 API fixture;没有上传实际文件,也未进行 Android/iOS 真机测试。
|
||||
|
||||
电脑端适配(2026-09-21):从 1024px 起,书架导航与搜索并排,书架、书库、书源和文件管理统一使用最大 80rem 内容宽度与两侧留白。文件操作收至标题右侧,列表采用文件名、元数据、菜单分列布局。书架设置改为右侧 26rem 面板,支持 Esc 关闭并恢复触发按钮焦点;缩窄窗口时恢复底部 sheet。
|
||||
|
||||
|
||||
阅读页桌面适配:正文采用 52rem 居中纸张区域,顶栏与纸张对齐;底部使用 50rem 悬浮控制条,进度与操作按钮用分隔线区分。纸张外围背景跟随当前主题,浅色、米黄、浅绿和夜间主题均使用主题色。目录使用左侧面板,界面、朗读和设置使用右侧面板,遮罩降低深度以便预览正文。面板支持 Esc 关闭及焦点恢复,聚焦不触发页面滚动。`desktop-reader.mjs` 验证三种桌面尺寸下四个面板、工具栏显隐、正文尺寸/页码/滚动位置不变、翻页模式、夜间主题,以及 1024×420 短窗口的滚轮操作。6 项阅读工具栏组件测试及移动端 `reader-regression.mjs` 全部通过,后者覆盖正文翻页、阅读信息、主题、插图、漫画、PDF 和 320px 面板。截图为 `desktop-reader-*.png`。
|
||||
|
||||
|
||||
正文翻页回跳修复:滚动模式原先用整章高度的 `100%` 做滑动动画,实际滚动 619px 后又产生约 3089px 的动画位移。现在用本次真实滚动距离,同时限制章末对齐位置不超过可滚动范围,避免最后一页页码失真。`page-turn-regression.mjs` 在 1366/390px 下逐帧采集正文坐标及滚动偏移,验证从首翻至末、再翻回首时不反向移动,且每次恰好改变一页;修复前该脚本能重现失败,修复后通过。构建、80 项 reader-view/shadow 测试及移动阅读回归通过。
|
||||
|
||||
书架设置吸顶修复:移除滚动区域顶部内边距,并取消首个分组标题的负外边距,避免标题吸顶时露出下方控件。Web 构建及浏览器检查通过;在 320/390/1280px 宽、568px 高的视口实际滚动面板,验证吸顶标题与面板头部无间隙,顶部命中区域始终由标题覆盖。截图为 `collections-shelf-settings-sticky-*.png`。
|
||||
|
||||
复现(先安装 Web 依赖):
|
||||
|
||||
```powershell
|
||||
npm --prefix web run build
|
||||
$env:CHROME_PATH='C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
node web/tools/ui-review/collections.mjs
|
||||
```
|
||||
|
||||
截图输出到本目录 `collections-*.png`,重点预览:`collections-shelf-empty-390.png`、`collections-library-empty-390.png`、`collections-shelf-books.png`、`collections-library-books.png`。
|
||||
@@ -1,50 +0,0 @@
|
||||
# 媒体页面 UI/UX 端到端评审
|
||||
|
||||
日期:2026-10-01
|
||||
范围:媒体首页、视频/电影/剧集、音乐/专辑、设置/主题;移动端 390px、桌面端 1120px;原型对照与生产构建。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `npm run build`:通过。
|
||||
- `npm run test`:114 个测试文件、995 项通过。
|
||||
- `media-v2-review.cjs`:14 组移动/桌面对照通过,`errors: []`。
|
||||
- 已验证:六套媒体主题、主题切换后的播放器/页面 token 一致性、视频与音乐分区布局、标题/列表的长文本处理、页面无横向溢出。
|
||||
- 播放器专项:视频 Plyr 初始化在当前评审素材下 12 秒超时,需补充真实视频播放状态与加载失败态的 E2E 回归。
|
||||
|
||||
## 优化建议
|
||||
|
||||
### P0:播放器加载态与失败态
|
||||
|
||||
视频播放按钮触发后应立即进入“正在加载”状态,超过阈值显示“重试 / 返回详情”,避免按钮无反馈造成误判。音频播放器与视频播放器应共用状态语义:待播放、加载中、播放中、暂停、不可用。
|
||||
|
||||
验收:点击播放后 300ms 内出现加载反馈;媒体资源超时或 4xx/5xx 时出现可操作错误;重试不会重复创建队列。
|
||||
|
||||
### P1:主题 token 与控件层级
|
||||
|
||||
当前主题变量已能覆盖媒体页、频道入口和播放器。后续继续统一 `background / paper / surface / text / muted / border / accent / selection`,并为禁用态、焦点态、错误态建立对比度检查。主操作每个分区只保留一个强调按钮,收藏、更多、管理等动作降为次级。
|
||||
|
||||
### P1:分区布局与导航
|
||||
|
||||
视频分区建议固定“频道标签 → 筛选/排序 → 内容网格 → 分页”的节奏;详情页保持“封面/标题/主操作 → 简介 → 版本/章节/资源”。在 390px 下筛选控件允许换行,按钮触控区保持 44px。
|
||||
|
||||
### P1:按钮可见性与语义
|
||||
|
||||
图标按钮必须同时有可访问名称;失败任务优先展示“重试”,删除放在次级菜单;播放器的“打开控制、歌词、睡眠定时、播放队列”保持固定顺序,避免随状态跳动。
|
||||
|
||||
### P2:信息密度
|
||||
|
||||
长标题使用两行截断并保留完整可访问名称;封面下元信息只保留作者/类型等一行关键字段。设置页按“播放、界面、主题、媒体库”分组,组间使用 16–24px 间距。
|
||||
|
||||
## 原型
|
||||
|
||||
高保真原型位于 [`docs/prototypes/media/v2-review/index.html`](../prototypes/media/v2-review/index.html),包含 42 个页面和状态、移动/桌面尺寸、六套主题,可直接在浏览器打开。其布局基准已用于本次 14 组生产页面对照。
|
||||
|
||||
## 复现命令
|
||||
|
||||
```powershell
|
||||
$env:PROTOTYPE_CHROMIUM='C:\Program Files\Google\Chrome\Application\chrome.exe'
|
||||
$env:MEDIA_REVIEW_SAMPLE_PACK='C:\Users\hector\Downloads\reader-media-acceptance-20260926'
|
||||
$env:MEDIA_V2_ONLY='video,movie,show,music,album,settings,themes'
|
||||
node web/tools/media-v2-review.cjs
|
||||
```
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Media UI/UX 验收与优化报告
|
||||
|
||||
日期:2026-10-01
|
||||
范围:媒体库、书架/书库、阅读器、扫描任务、播放页;移动端 390×844,桌面端 1120×900 生产构建场景。
|
||||
|
||||
## 已落地优化
|
||||
|
||||
### 扫描任务的恢复路径
|
||||
|
||||
失败扫描记录此前只有“删除历史”图标,用户无法从当前上下文恢复任务。现在失败记录同时提供“重新扫描媒体库”入口;当同一媒体库仍有排队或进行中的任务时,重试按钮会禁用并显示等待语义,避免重复提交。
|
||||
|
||||
### 扫描任务与 AI 刷新解耦
|
||||
|
||||
普通扫描轮询不再依赖 AI 扫描接口或完整对象上下文才能完成状态更新。这样扫描列表可以稳定呈现“进行中 / 失败 / 完成”,减少局部刷新时的空白、误导性加载和状态跳变。
|
||||
|
||||
## 视觉与交互观察
|
||||
|
||||
### P0:阅读页顶部内容可能被工具栏遮挡
|
||||
|
||||
移动端夜间主题截图中,章节标题的第一行贴近顶部工具栏,出现被遮挡的视觉风险。阅读正文应从工具栏安全区之后开始,尤其是在滚动恢复、切换主题、收起/展开工具栏后保持一致。
|
||||
|
||||
建议:将正文起始安全区绑定到实际 topbar 高度和 safe-area,而不是固定的短间距;切换主题和恢复阅读位置后重新校正滚动锚点。验收条件:章节标题不被遮挡,滚动到章节顶部时首行完整可读。
|
||||
|
||||
### P1:扫描任务操作需要从图标识别升级为语义分组
|
||||
|
||||
当前列表右侧使用删除、取消、重试图标,桌面端可理解,手机端在长列表中识别成本较高。
|
||||
|
||||
建议:失败状态将“重新扫描”作为主操作,删除作为次要操作;在 430px 以下的断点使用“重试”文字按钮或图标+文字,并在任务错误说明下提供恢复动作。保留 44px 最小触控区域。
|
||||
|
||||
### P1:任务状态应形成清晰的视觉层级
|
||||
|
||||
进行中任务使用边框卡片,失败和完成任务以普通行展示,状态差异明确但恢复动作不够突出。
|
||||
|
||||
建议:为进行中、失败、完成建立稳定的状态色和状态图标映射,同时保证暗色主题下文字与边框达到可读对比度;不要只依赖颜色表达状态,配合状态文字和图标。
|
||||
|
||||
### P1:设置页分组密度可进一步收紧
|
||||
|
||||
阅读设置底部存在较大空白,分段控件与标签的左右关系清晰,但内容密度偏低。
|
||||
|
||||
建议:将相关设置按“阅读方式 / 翻页行为 / 动画”分组,并使用组标题和短说明消除空白;在手机端每组之间保持 16–24px 节奏,避免单个控件漂浮。
|
||||
|
||||
### P2:媒体库卡片信息可读性
|
||||
|
||||
书架三列卡片在 390px 宽度下可用,但书名和作者信息依赖截断;操作菜单位于封面右上角,视觉上容易被误认为封面内容。
|
||||
|
||||
建议:长按或点击卡片后再显示操作菜单,或增加更明确的“更多”辅助文字;保证书名至少显示两行,作者显示一行,类型徽章不覆盖封面主体。
|
||||
|
||||
### P2:文件管理页需要更强的当前位置反馈
|
||||
|
||||
文件管理页的面包屑、文件列表、分页结构完整,但在深色主题和长列表中,当前目录与分页信息的视觉权重接近。
|
||||
|
||||
建议:强化当前目录名称,弱化技术性文件大小;分页器增加“上一页 / 下一页”语义标签,并在移动端固定在列表底部安全区内。
|
||||
|
||||
### P2:主题系统整体一致,但媒体与阅读器应共享语义 token
|
||||
|
||||
截图显示媒体页和阅读页都具备浅色、深色、米黄等主题,但两套变量命名和对比策略不同,后续容易出现“媒体页已切换,播放层或弹层仍使用旧背景”的问题。
|
||||
|
||||
建议:统一 `surface / paper / text / muted / border / accent / selection` 语义 token,并让播放器、弹层、任务面板只消费语义变量;为每个主题建立文本、控件、禁用态和焦点态对比度验收表。
|
||||
|
||||
## 验收结果
|
||||
|
||||
- 生产构建:通过。
|
||||
- 媒体全量单元/组件测试:114 个测试文件、995 项全部通过。
|
||||
- OpenList 真实媒体链路:通过,包含创建、扫描、凭据脱敏、远程音频播放、Range 请求和凭据轮换。
|
||||
- 移动端 UI Review:既有报告 32 项视觉/交互检查全部通过,覆盖主题、面板、书架、书库、搜索、分页、窄屏和图片溢出。
|
||||
|
||||
## 后续建议
|
||||
|
||||
优先补充真实浏览器下的媒体任务页场景截图,分别覆盖桌面 1120px、手机 390px、窄屏 320px,以及失败任务、重试禁用、重试成功、深色主题四种状态。这样可以把本轮已修复的操作层级问题纳入持续视觉回归。
|
||||
@@ -1,246 +0,0 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"name": "01-shelf",
|
||||
"label": "书架",
|
||||
"what": "第一屏:先看到书,再看到控件"
|
||||
},
|
||||
{
|
||||
"name": "02-reader",
|
||||
"label": "阅读页",
|
||||
"what": "顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口"
|
||||
},
|
||||
{
|
||||
"name": "03-reader-no-chrome",
|
||||
"label": "阅读页 · 收起工具栏",
|
||||
"what": "工具栏一起收起,正文保持原位置,底部只留书名、页码与进度"
|
||||
},
|
||||
{
|
||||
"name": "04-panel-toc",
|
||||
"label": "目录 · 半屏",
|
||||
"what": "下半屏,上半屏正文仍可见"
|
||||
},
|
||||
{
|
||||
"name": "05-panel-settings",
|
||||
"label": "阅读设置 · 半屏",
|
||||
"what": "一行式行:标签在左、控件在右"
|
||||
},
|
||||
{
|
||||
"name": "06-reader-paged",
|
||||
"label": "阅读页 · 翻页模式",
|
||||
"what": "分栏后的一页,页数应与可翻次数一致"
|
||||
},
|
||||
{
|
||||
"name": "07-reader-sepia",
|
||||
"label": "阅读页 · 米黄",
|
||||
"what": "主题切换后的同一页"
|
||||
},
|
||||
{
|
||||
"name": "08-reader-dark",
|
||||
"label": "阅读页 · 夜间",
|
||||
"what": "暗色下的正文与工具栏"
|
||||
},
|
||||
{
|
||||
"name": "09-panel-txt",
|
||||
"label": "TXT · 正文排版",
|
||||
"what": "纯文本专属的缩进/段间距/编码三行,且面板仍是半屏"
|
||||
},
|
||||
{
|
||||
"name": "10-shelf-paged",
|
||||
"label": "书架 · 第二页",
|
||||
"what": "没有「继续阅读」横排;默认按最近阅读排序(第一个 chip 是填充态);分页器在封面下方;书库与书源入口在标题下方"
|
||||
},
|
||||
{
|
||||
"name": "11-library",
|
||||
"label": "书库 · 浏览",
|
||||
"what": "搜索框与封面网格;「加入书架」只画在不在书架的书上,管理员可进入文件管理;没有上传或书架设置入口"
|
||||
},
|
||||
{
|
||||
"name": "12-library-files",
|
||||
"label": "书库 · 文件管理",
|
||||
"what": "管理员文件页:面包屑与文件行、书籍状态徽章、上传与新建;返回按钮在子目录回上级,在根目录回书库浏览页"
|
||||
},
|
||||
{
|
||||
"name": "17-shelf-unshelve",
|
||||
"label": "书架 · 从书架拿掉",
|
||||
"what": "封面右上角的「⋯」打开后的动作菜单,以及「从书架拿掉」的确认框:写清只是从书架拿掉、磁盘上的文件不动"
|
||||
},
|
||||
{
|
||||
"name": "15-library-preview-shelve",
|
||||
"label": "书库 · 浏览 · 上架",
|
||||
"what": "点了某本书的「加入书架」之后:报告、目录重读,以及角标消失的那一本"
|
||||
},
|
||||
{
|
||||
"name": "16-library-search",
|
||||
"label": "书库 · 浏览 · 搜索",
|
||||
"what": "商城式布局的搜索框:输入之后 URL 带上查询、网格只剩匹配的书,字段两边的留白让控件不贴着封面"
|
||||
},
|
||||
{
|
||||
"name": "13-reader-paged-scrub",
|
||||
"label": "阅读页 · 滑杆拖到本章第 3 页",
|
||||
"what": "分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章"
|
||||
},
|
||||
{
|
||||
"name": "14-reader-epub-image",
|
||||
"label": "EPUB · 插图",
|
||||
"what": "章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"name": "手机阅读交互回归",
|
||||
"ok": true,
|
||||
"detail": "reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏"
|
||||
},
|
||||
{
|
||||
"name": "翻页不再每次都发两次 sync",
|
||||
"ok": true,
|
||||
"detail": "8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求)"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 章节里的图片真的画出来了",
|
||||
"ok": true,
|
||||
"detail": "naturalWidth=240 显示 240x160 src=\"/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI\""
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 插图下面的正文也在",
|
||||
"ok": true,
|
||||
"detail": "正文开头=\"台版 转自 天使动漫\n\n插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。\""
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 比页面宽的插图被收进栏内",
|
||||
"ok": true,
|
||||
"detail": "出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2)"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 收窄插图没有改变它的比例",
|
||||
"ok": true,
|
||||
"detail": "原图 8.750 / 画出来 8.752"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 宽插图没有让页面横向溢出",
|
||||
"ok": true,
|
||||
"detail": "document 溢出 0px / 阅读面溢出 0px"
|
||||
},
|
||||
{
|
||||
"name": "搜索框: 两边都从封面列内缩,且左右相等",
|
||||
"ok": true,
|
||||
"detail": "左 14px / 右 14px(字段 0 则为贴着封面)"
|
||||
},
|
||||
{
|
||||
"name": "搜索框: 放大镜与输入框中线对齐",
|
||||
"ok": true,
|
||||
"detail": "放大镜中线 173.0 / 输入框中线 173.0(偏差 -0.0px,字形 16.796875px,框高 48px)"
|
||||
},
|
||||
{
|
||||
"name": "书架: 第一个排序是「最近阅读」且默认选中",
|
||||
"ok": true,
|
||||
"detail": "chips=最近阅读* / 最近入库 / 书名 / 作者"
|
||||
},
|
||||
{
|
||||
"name": "书架: 「最近入库」仍然在排序里",
|
||||
"ok": true,
|
||||
"detail": "chips=最近阅读 / 最近入库 / 书名 / 作者"
|
||||
},
|
||||
{
|
||||
"name": "书架: 排在最前的是最近读过的那本",
|
||||
"ok": true,
|
||||
"detail": "前两本:剑来 第1卷 / 剑来 第2卷"
|
||||
},
|
||||
{
|
||||
"name": "书架: 没有「继续阅读」横排",
|
||||
"ok": true,
|
||||
"detail": "continue-row=false 卡片 0 张"
|
||||
},
|
||||
{
|
||||
"name": "书架: 每张封面有一个「⋯」操作入口,按得着也看得见",
|
||||
"ok": true,
|
||||
"detail": "28.0×28.0 在卡片内=true / 开书区覆盖整卡=true / 静止不透明度 0.55"
|
||||
},
|
||||
{
|
||||
"name": "书库: 浏览页是封面网格,没有文件行",
|
||||
"ok": true,
|
||||
"detail": "封面 60 张 / 文件行 0 行"
|
||||
},
|
||||
{
|
||||
"name": "书库: 浏览页有搜索框(商城式布局)",
|
||||
"ok": true,
|
||||
"detail": "搜索框=true label=\"搜索书库\""
|
||||
},
|
||||
{
|
||||
"name": "书库: 搜索框两边留白,不贴着封面",
|
||||
"ok": true,
|
||||
"detail": "字段左 12px / 右 12px,封面比字段再内缩 14px"
|
||||
},
|
||||
{
|
||||
"name": "书库: 浏览页的网格铺满窗口,而不是被书架的宽度上限截住",
|
||||
"ok": true,
|
||||
"detail": "网格 390px / 窗口 390px,封面占 30 行"
|
||||
},
|
||||
{
|
||||
"name": "书库: 浏览页提供文件管理入口,不放上传、新建或书架设置",
|
||||
"ok": true,
|
||||
"detail": "头部控件=返回 / 文件管理"
|
||||
},
|
||||
{
|
||||
"name": "书库: 浏览页只给不在书架的书画「加入书架」,且不是全给",
|
||||
"ok": true,
|
||||
"detail": "角标 1 个 / 卡片 60 张,提示=\"这一页有 1 本还不在书架上,点封面上的按钮可以加进去\""
|
||||
},
|
||||
{
|
||||
"name": "书库: 输入搜索词后 URL 与网格一起变,只剩匹配的书",
|
||||
"ok": true,
|
||||
"detail": "前 60 张 → 后 2 张(剑来 第1卷 / 第一卷 插图),hash=#/library?q=%E7%AC%AC1%E5%8D%B7"
|
||||
},
|
||||
{
|
||||
"name": "书库: 搜索词进 URL,成为可以发给别人的链接",
|
||||
"ok": true,
|
||||
"detail": "hash=#/library?q=%E7%AC%AC1%E5%8D%B7 字段=\"第1卷\""
|
||||
},
|
||||
{
|
||||
"name": "书库: 文件管理页画的是文件行,没有封面网格",
|
||||
"ok": true,
|
||||
"detail": "view=files 行 4 / 封面 0,hash=#/library/files"
|
||||
},
|
||||
{
|
||||
"name": "书库: 文件管理页有上传/新建,没有搜索框",
|
||||
"ok": true,
|
||||
"detail": "右上角=返回 / 上传书籍 / 新建文件夹 / 书库,搜索框=false"
|
||||
},
|
||||
{
|
||||
"name": "书库: 文件管理页提供返回按钮,不使用浏览页签",
|
||||
"ok": true,
|
||||
"detail": "头部控件=返回 / 上传书籍 / 新建文件夹 / 书库"
|
||||
},
|
||||
{
|
||||
"name": "书库: 文件管理页没有「下架 / 从书架拿掉 / 加入书架」任何一处",
|
||||
"ok": true,
|
||||
"detail": "页面文本命中=无"
|
||||
},
|
||||
{
|
||||
"name": "书库: 文件管理页仍然标出不在书架的书(徽章不是控件)",
|
||||
"ok": true,
|
||||
"detail": "徽章=true"
|
||||
},
|
||||
{
|
||||
"name": "书库: 从文件管理回到浏览是换页,URL 也跟着换",
|
||||
"ok": true,
|
||||
"detail": "浏览=true 文件=false hash=#/library 封面 60 张"
|
||||
},
|
||||
{
|
||||
"name": "书架标题栏: 书架设置图标可见,且不超出按钮",
|
||||
"ok": true,
|
||||
"detail": "书架设置 · 适中: 18.4×18.4 in 44 box, inside=true"
|
||||
},
|
||||
{
|
||||
"name": "书架标题栏: 图标不比标题大(同一行的视觉重量)",
|
||||
"ok": true,
|
||||
"detail": "图标 18.4 vs 标题 28"
|
||||
}
|
||||
],
|
||||
"failures": []
|
||||
}
|
||||
@@ -604,58 +604,18 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
|
||||
## 7. 端到端测试要做 UI 评审,不能只看功能
|
||||
|
||||
功能测试问的是「点了会不会变」,UI 评审问的是「变了之后能不能用」。两者可以
|
||||
同时给出相反的回答,而且这在真实项目里是常态——下面每一条都曾在功能全绿的同时
|
||||
是错的:
|
||||
功能测试验证交互和数据,UI 评审验证真实页面在目标视口中的布局、可见性和溢出。两者使用同一份生产 Web bundle,但关注点不同:功能测试不能代替截图检查,截图也不能代替可自动断言的尺寸检查。
|
||||
|
||||
| 曾经的形态 | 功能测试怎么看 | 读者怎么看 |
|
||||
| --- | --- | --- |
|
||||
| 目录是 `inset: 0` 全屏页 | ✅ 点开有面板,点条目会跳转 | 为了选一章而离开那一页 |
|
||||
| 翻页步长 0.9 屏 | ✅ `scrollTop` 变大了 | 每次翻页留一条上一页的尾巴 |
|
||||
| 页数按内容高度算 | ✅ 画出了 `本章 x/y 页` | 翻到最后一页时数字不认账 |
|
||||
| 状态行是 flex 里的一行 | ✅ 文案正确渲染 | 每次同步都把他正在读的那句推走 |
|
||||
| 图标按钮自己拼的 | ✅ 点击有反应 | 图标和文字差半个字高 |
|
||||
| 底栏滑杆量的是全书百分比 | ✅ 拖了会动、页码也变了 | 想翻到本章第 7 页,被带到了第 40 章 |
|
||||
| 快捷按钮列只在沉浸态出现 | ✅ 收起工具栏后按钮还在 | 收起后正文右边仍压着一列浮层 |
|
||||
| 朗读条是 flex 里的一行 | ✅ 按钮渲染出来了、`aria-label` 也在 | 它是静态兄弟、被 `inset: 0` 的 stage 盖住,实心按钮一个都点不到 |
|
||||
| 朗读条的控件挤在一行里 | ✅ 每个控件单独看都在 DOM 里 | 手机宽度下最后一个(停止)被挤出屏幕外,那就是「没法停止」 |
|
||||
| 快捷列装的是打开面板的入口 | ✅ 每个按钮都能点开东西 | 浮在正文上的第二份设置面板,比面板本身更难用 |
|
||||
| 版心按工具栏高度留固定边距 | ✅ 开关工具栏时正文一行不动 | 两种状态下页面都矮 9rem,收起了工具栏也还是两块空白纸 |
|
||||
| 分栏的 column-gap 是 0、页边距是内边距 | ✅ 滑杆能拖、页码会变 | 每页右边缘露出下一页的头几个字,翻页「样式不对」 |
|
||||
当前入口是 `npm run ui:review`。它运行 `web/tools/ui-review/react-app.mjs`,用真实 Chromium 加载构建后的 React 应用,覆盖书架、书库、书源、设置和影音页面的移动端与桌面端场景。默认产物写入被 Git 忽略的 `artifacts/ui-review/react-app/`,包含场景截图和 `verification.json`。
|
||||
|
||||
所以 `npm run ui:review` 做两件事,两件都要:
|
||||
`verification.json` 是 CI 读取的机器结果。提交或排查 UI 改动时,先检查 `passed`、页面错误、横向溢出和场景列表,再查看对应截图。截图数量随场景变化,不在文档中固定写死。
|
||||
|
||||
1. **截图**(`docs/ui-review/*.png`)。这一步没有断言可以替代。「只是丑」不是
|
||||
测试失败,但它是缺陷。截图交给评审的人看。
|
||||
2. **量尺寸**(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人
|
||||
不在流水线里。当前量的是:顶栏/底栏/快捷列三条 band 一起收放、收起时正文占满
|
||||
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
|
||||
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
|
||||
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
|
||||
还有几条是读者报告补进来的,每一条都先是**截图**再是断言:
|
||||
**出版方写了 1400px 的插图**收进栏内且**比例不变**(只夹宽度会把它压扁,
|
||||
所以比例必须单独量);**快捷列**在 390×844 和 390×420 两种屏高下都在页内,
|
||||
装不下的控件滚得到、滚动条画了出来(`scrollbarWidth > 0`,而不是读回
|
||||
`overflow-y`);**快捷列**里确实有「听书」。
|
||||
书库拆成两个屏幕之后又有六条(§3.4.1):
|
||||
浏览页有**搜索框**而文件管理页没有;浏览页的网格**铺满窗口**而不是被书架的
|
||||
宽度上限截住(量网格宽度和窗口宽度,再加封面占了几行);**字段两边的留白**
|
||||
左右相等且封面比它再内缩(`padding: 0` 时量出来是 0,这一条正好抓到了一个
|
||||
token 挂在别的元素上、留白静默没生效);**输入搜索词之后 URL 和网格一起变**,
|
||||
而且是**真敲**进去的,不是直接设 `value`——字段有防抖,防抖才是「查询变成一次
|
||||
导航」的那一步,直接设值会在一张 URL 从未改变过的页面上通过;文件管理页画的
|
||||
是**行**、有上传/新建、**没有**搜索框;以及从文件管理按「浏览书籍」是**换页**,
|
||||
URL 跟着换。
|
||||
加入/移除补全之后又加了一条(§3.5.3):**每张封面上的「⋯」按得着也看得见**——
|
||||
量的是命中区(≥28px,和顶栏控件同一个下限)和它**在卡片之内**,外加两条反
|
||||
命题:打开区仍然覆盖整张卡(放大字形不能吃掉开书的那块面积),以及静止不透明度
|
||||
不低于 0.5(一个量得到、但看不见的按钮,功能测试全绿)。
|
||||
阅读器和书源的专项检查仍可单独运行:
|
||||
|
||||
它跑在**真实的生产产物**上:`npm run build` 之后由 `tools/ui-review/server.mjs`
|
||||
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
|
||||
组件故事会喂进手写的 state,而上面那些缺陷全都发生在「屏幕对真实响应」的时候。
|
||||
- `npm run ui:review:reader --prefix web`:阅读器舞台、面板、翻页和窄屏场景,输出到 `artifacts/ui-review/reader/`;
|
||||
- `npm run ui:sources --prefix web`:书源页面与扩展流程,输出到 `artifacts/ui-review/sources/`。
|
||||
|
||||
断言挂在 `client-ui-review` 流水线上,只在界面相关文件变化时触发。
|
||||
这些脚本只生成临时评审产物,不要把截图或报告提交到 `docs/`。CI 的 `client-ui-review` 只在界面代码、界面规范或评审工具变化时运行。
|
||||
|
||||
---
|
||||
|
||||
@@ -718,5 +678,5 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
| `web/test/gestures.test.ts` | 事件层级的优先级契约 |
|
||||
| `web/test/reader-paging.test.ts` | 翻页的页序号算术(含子像素取整) |
|
||||
| `web/tools/ui-review/` | UI 评审:截图 + 尺寸断言(§7) |
|
||||
| `docs/ui-review/` | 评审截图与 `report.json`(生成,随 PR 一起看) |
|
||||
| `artifacts/ui-review/react-app/` | 当前 React 评审截图与 `verification.json`(生成,不提交) |
|
||||
| `web/src/styles/reader.css` | 令牌、组件样式、交互状态、`prefers-reduced-motion` |
|
||||
@@ -27,6 +27,10 @@ export function resolveCorsOrigin(config: AppConfig, request: FastifyRequest): s
|
||||
|
||||
if (config.corsOrigins.includes(origin)) return origin;
|
||||
|
||||
const proto = (String(request.headers['x-forwarded-proto'] ?? request.protocol ?? 'http').split(',')[0] ?? '').trim();
|
||||
const host = (String(request.headers['x-forwarded-host'] ?? request.headers.host ?? '').split(',')[0] ?? '').trim();
|
||||
if (host && origin === `${proto}://${host}`) return origin;
|
||||
|
||||
// `file://` requests arrive with the literal origin "null". The Android shell
|
||||
// needs this whenever the H5 bundle is loaded from assets, and it cannot be
|
||||
// expressed as a normal origin entry.
|
||||
@@ -43,5 +47,12 @@ export function isOriginAllowed(config: AppConfig, request: FastifyRequest): boo
|
||||
// accepted even when a public deployment narrows browser origins.
|
||||
if (origin === 'https://appassets.androidplatform.net') return true;
|
||||
if (config.corsOrigins.length === 0) return true;
|
||||
// A deployment may serve the H5 bundle from the same host as the API while
|
||||
// sharing its persisted settings with another hostname. Treat that request
|
||||
// as same-origin even when the stored allowlist belongs to the other host.
|
||||
// Reverse proxies provide the public scheme/host through these headers.
|
||||
const proto = (String(request.headers['x-forwarded-proto'] ?? request.protocol ?? 'http').split(',')[0] ?? '').trim();
|
||||
const host = (String(request.headers['x-forwarded-host'] ?? request.headers.host ?? '').split(',')[0] ?? '').trim();
|
||||
if (host && origin === `${proto}://${host}`) return true;
|
||||
return config.corsOrigins.includes(origin) || (origin === 'null' && config.corsOrigins.includes('null'));
|
||||
}
|
||||
@@ -14,3 +14,16 @@ test('Android origin is allowed and echoed with a restricted H5 allowlist', () =
|
||||
assert.equal(isOriginAllowed(config, { headers: { origin: other } } as FastifyRequest), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the public host is allowed when persisted CORS settings belong to another deployment', () => {
|
||||
const config = { corsOrigins: ['https://reader.example.com'] } as AppConfig;
|
||||
const request = {
|
||||
protocol: 'http',
|
||||
headers: {
|
||||
origin: 'https://reader-uat.example.com',
|
||||
host: 'reader-uat.example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
assert.equal(isOriginAllowed(config, request), true);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Navigate, Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../shared/stores/auth.store.ts';
|
||||
import { useRuntime } from './providers/runtime-context.tsx';
|
||||
import { LoginPage } from '../features/auth/pages/LoginPage.tsx';
|
||||
import { MiniPlayer } from '../features/media/components/MiniPlayer.tsx';
|
||||
import { MediaChannelEntry } from '../features/media/components/channel-navigation.tsx';
|
||||
import { MediaThemeController } from '../features/media/services/theme.ts';
|
||||
import { FloatingNotice } from '../ui/floating-notice.tsx';
|
||||
|
||||
export function AppShell() {
|
||||
return <AuthBoundary><AuthenticatedShell /></AuthBoundary>;
|
||||
@@ -13,11 +13,8 @@ export function AppShell() {
|
||||
|
||||
function AuthenticatedShell() {
|
||||
const runtime = useRuntime();
|
||||
const user = useAuthStore(state => state.verifiedUser);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const reading = location.pathname.startsWith('/book/');
|
||||
const media = location.pathname.startsWith('/media');
|
||||
useEffect(() => {
|
||||
const openControls = () => {
|
||||
const state = runtime.playback.snapshot();
|
||||
@@ -27,14 +24,11 @@ function AuthenticatedShell() {
|
||||
return () => runtime.player.removeEventListener('open-controls', openControls);
|
||||
}, [runtime, navigate]);
|
||||
useEffect(() => runtime.player.setVisible(location.pathname.startsWith('/media/')), [runtime, location.pathname]);
|
||||
return <>
|
||||
{!reading && !media && <header className="app-global-nav" aria-label="主导航">
|
||||
<Link to="/shelf">书架</Link><Link to="/library">书库</Link><Link to="/sources">书源</Link><Link to="/media/video">影音</Link><Link to="/settings">设置</Link>
|
||||
<span style={{ marginInlineStart: 'auto' }}>{user?.displayName || user?.username || ''}</span>
|
||||
<button type="button" onClick={() => void runtime.api.signOut()}>退出登录</button>
|
||||
</header>}
|
||||
<Outlet /><MiniPlayer />
|
||||
</>;
|
||||
// Reading-adjacent pages own their header and discovery actions. The migrated
|
||||
// shell used to inject a second, generic navigation bar above every page, which
|
||||
// changed the vertical rhythm and made shelf/library diverge from the legacy UI.
|
||||
// Media keeps its own channel navigation through MediaLayout.
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthBoundary({ children }: { children?: ReactNode }) {
|
||||
@@ -44,18 +38,29 @@ export function AuthBoundary({ children }: { children?: ReactNode }) {
|
||||
}
|
||||
|
||||
export function MediaLayout() {
|
||||
const runtime = useRuntime();
|
||||
const location = useLocation();
|
||||
const current = location.pathname.startsWith('/media/music')
|
||||
? 'music'
|
||||
: location.pathname.startsWith('/media/audiobook')
|
||||
? 'audiobook'
|
||||
: 'video';
|
||||
return <><MediaChannelEntry current={current} /><Outlet /></>;
|
||||
// The legacy media shell applies its own palette while any media route is
|
||||
// mounted. Keep this scoped to MediaLayout so reading pages continue to use
|
||||
// the reader palette, and let React CSS consume the injected theme tokens.
|
||||
useEffect(() => {
|
||||
const theme = new MediaThemeController(runtime.mediaApi.preferenceScope(), () => undefined);
|
||||
return () => theme.dispose();
|
||||
}, [runtime]);
|
||||
// Keep the channel rail after the page content in the DOM, matching the legacy
|
||||
// shell. Its presentation stylesheet may pin it to the viewport, while the
|
||||
// route-owned page remains the first and primary content surface.
|
||||
return <><Outlet /><MediaChannelEntry current={current} /></>;
|
||||
}
|
||||
|
||||
export function HomeRedirect() { return <Navigate to="/media/video" replace />; }
|
||||
|
||||
export function LoadingPage() { return <Center mih="50vh"><Loader /></Center>; }
|
||||
export function LoadingPage() { return <FloatingNotice message="正在加载…" busy />; }
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { RouteErrorBoundary } from './error-boundary.tsx';
|
||||
import { ShelfPage } from '../../features/shelf/pages/ShelfPage.tsx';
|
||||
import { LibraryPage, LibraryBrowsePage, LibraryFilesPage } from '../../features/library/pages/LibraryPage.tsx';
|
||||
import { SourcesPage } from '../../features/sources/pages/SourcesPage.tsx';
|
||||
import { SettingsPage } from '../../features/settings/pages/SettingsPage.tsx';
|
||||
import { ReaderPage } from '../../features/reader/pages/ReaderPage.tsx';
|
||||
import { MediaLibrariesPage, MediaLibraryCreatePage } from '../../features/media/pages/MediaLibrariesPage.tsx';
|
||||
import { MediaLibraryEditPage, MediaLibraryPermissionsPage } from '../../features/media/pages/MediaLibraryEditorPages.tsx';
|
||||
@@ -40,15 +39,24 @@ export const routes = [{
|
||||
{ path: 'library/books', element: <LibraryPage /> },
|
||||
{ path: 'library/files', element: <LibraryFilesPage /> },
|
||||
{ path: 'sources', element: <SourcesPage /> },
|
||||
{ path: 'sources/search', element: <SourceCatalogPage /> },
|
||||
// The legacy hash route /sources/search opens the complete sources hub;
|
||||
// catalog browsing remains available through the source-specific route.
|
||||
{ path: 'sources/search', element: <SourcesPage /> },
|
||||
{ path: 'sources/manage/new', element: <SourceEditorPage /> },
|
||||
{ path: 'sources/manage/:sourceId', element: <SourceEditorPage /> },
|
||||
{ path: 'sources/:sourceId/browse', element: <SourceCatalogPage /> },
|
||||
{ path: 'sources/:sourceId/credentials', element: <SourceCredentialsPage /> },
|
||||
{ path: 'sources/plugins', element: <PluginManagementPage /> },
|
||||
{ path: 'sources/:sourceId/pages/:pageId', element: <SourceExtensionPage /> },
|
||||
// Legacy source extension links use /sources/:sourceId/:pageId. Keep the
|
||||
// old deep-link shape working while the React shell emits the namespaced
|
||||
// /pages form for new links.
|
||||
{ path: 'sources/:sourceId/:pageId', element: <SourceExtensionPage /> },
|
||||
{ path: 'sources/plugins/:pluginId/pages/:pageId', element: <SourceExtensionPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
// The legacy shell treats /settings as the shelf's admin settings overlay.
|
||||
// Keep the route compatible with old deep links; user preferences live in
|
||||
// the shelf settings sheet and media settings has its own nested route.
|
||||
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
|
||||
{ path: 'book/:bookId', element: <ReaderPage /> },
|
||||
{ path: 'media', element: <MediaLayout />, children: [
|
||||
{ index: true, element: <Navigate to="video" replace /> },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, Badge, Button, Center, Divider, Group, Loader, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import type { InstanceInfo } from '../../../api/types.ts';
|
||||
import { ApiError } from '../../../api/errors.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -14,6 +14,7 @@ export function LoginPage() {
|
||||
const [serverUrl, setServerUrl] = useState(runtime.api.baseUrl);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [registering, setRegistering] = useState(false);
|
||||
@@ -68,5 +69,20 @@ export function LoginPage() {
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const canRegister = !!instance?.registrationOpen;
|
||||
return <Center className="login-screen" mih="100vh" p="md"><form onSubmit={submit} style={{ width: 'min(100%, 28rem)' }}><Stack gap="md"><Stack gap={2}><Title order={1}>reader</Title><Text c="dimmed">你的私人书房</Text></Stack><details className="login-connection"><summary>连接设置<span>更换服务地址</span></summary><TextInput label="服务端地址" type="url" value={serverUrl} onChange={event => setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required rightSection={probing ? <Loader size="xs" /> : instance ? <Badge color="green">在线</Badge> : undefined} /><Group justify="space-between" align="center"><Text size="sm" c="dimmed">{instance?.name || '尚未连接服务端'}</Text>{instance && <Text size="xs" c="dimmed">API v{instance.apiVersion}</Text>}</Group></details><Divider /><TextInput label="用户名" autoComplete="username" value={username} onChange={event => setUsername(event.currentTarget.value)} required />{registering && <TextInput label="显示名(可选)" value={displayName} onChange={event => setDisplayName(event.currentTarget.value)} />}{registering && instance?.invitationRequired && <TextInput label="邀请码" value={inviteCode} onChange={event => setInviteCode(event.currentTarget.value)} required />}{registering && <Text size="xs" c="dimmed">注册后会自动登录并保存此服务端地址。</Text>}<PasswordInput label="密码" autoComplete={registering ? 'new-password' : 'current-password'} value={password} onChange={event => setPassword(event.currentTarget.value)} required />{notice && <Alert color={instance ? 'blue' : 'yellow'}>{notice}</Alert>}{error && <Alert color="red">{error}</Alert>}<Button className="login-submit" type="submit" loading={busy} disabled={!validServerUrl(serverUrl)}>{registering ? '注册并登录' : '登录'}</Button><Text className="login-session-hint">登录状态自动保持,无需每天重新登录</Text>{(canRegister || registering) && <Button type="button" variant="subtle" disabled={busy} onClick={() => setRegistering(value => !value)}>{registering ? '返回登录' : '注册新账号'}</Button>}</Stack></form></Center>;
|
||||
return <div className="login-screen"><form className="centered-form login-card" onSubmit={submit}>
|
||||
<header className="login-heading"><div className="login-brand">reader</div><p>你的私人书房</p></header>
|
||||
<details className="login-connection">
|
||||
<summary>连接设置<span>更换服务地址</span></summary>
|
||||
<div className="field"><label htmlFor="login-server">服务端地址 <em>*</em></label><div className="login-server-input"><input id="login-server" type="url" value={serverUrl} onChange={event => setServerUrl(event.currentTarget.value)} placeholder="http://127.0.0.1:5888" required />{probing ? <span className="login-server-status is-probing">…</span> : instance ? <span className="login-server-status">在</span> : null}</div><div className="login-server-meta"><span>{instance?.name || '尚未连接服务端'}</span>{instance && <span>API v{instance.apiVersion}</span>}</div></div>
|
||||
</details>
|
||||
<div className="field"><label htmlFor="login-username">用户名 <em>*</em></label><input id="login-username" autoComplete="username" value={username} onChange={event => setUsername(event.currentTarget.value)} required /></div>
|
||||
{registering && <div className="field"><label htmlFor="login-display-name">显示名(可选)</label><input id="login-display-name" value={displayName} onChange={event => setDisplayName(event.currentTarget.value)} /></div>}
|
||||
{registering && instance?.invitationRequired && <div className="field"><label htmlFor="login-invite">邀请码 <em>*</em></label><input id="login-invite" value={inviteCode} onChange={event => setInviteCode(event.currentTarget.value)} required /></div>}
|
||||
<div className="field"><label htmlFor="login-password">密码 <em>*</em></label><div className="login-password-input"><input id="login-password" type={showPassword ? 'text' : 'password'} autoComplete={registering ? 'new-password' : 'current-password'} value={password} onChange={event => setPassword(event.currentTarget.value)} required /><button type="button" className="login-password-toggle" aria-label={showPassword ? '隐藏密码' : '显示密码'} onClick={() => setShowPassword(value => !value)}>{showPassword ? <EyeOff size={18} aria-hidden="true" /> : <Eye size={18} aria-hidden="true" />}</button></div></div>
|
||||
{registering && <p className="login-register-hint">注册后会自动登录并保存此服务端地址。</p>}
|
||||
{notice && <div className={'login-notice' + (instance ? '' : ' is-warning')} role="status">{notice}</div>}{error && <div className="login-notice is-error" role="alert">{error}</div>}
|
||||
<button className="button primary login-submit" type="submit" disabled={!validServerUrl(serverUrl) || busy}>{busy ? '登录中…' : registering ? '注册并登录' : '登录'}</button>
|
||||
<p className="login-session-hint">登录状态自动保持,无需每天重新登录</p>
|
||||
{(canRegister || registering) && <div className="login-switch"><span>{registering ? '已有账号?' : '还没有账号?'}</span><button type="button" disabled={busy} onClick={() => setRegistering(value => !value)}>{registering ? '返回登录' : '注册新账号'}</button></div>}
|
||||
</form></div>;
|
||||
}
|
||||
@@ -1,90 +1,98 @@
|
||||
import { Alert, Badge, Button, Card, Center, Group, Loader, Pagination, SimpleGrid, Stack, Table, Text, TextInput, Title, Breadcrumbs, Anchor, Checkbox, Menu, ActionIcon, Divider } from '@mantine/core';
|
||||
import { Alert, Badge, Button, ActionIcon, Menu } from '@mantine/core';
|
||||
import { ArrowLeft, BookOpen, FileText, Folder, MoreHorizontal, Plus, Search, Upload } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import type { Book, BrowseEntry } from '../../../api/types.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { formatBytes, formatDate } from '../../../ui/dom.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
function Cover({ book }: { book: Book }) {
|
||||
const runtime = useRuntime();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
useEffect(() => { let live = true; if (!book.coverUrl) { setSrc(null); return; } void runtime.api.coverBytes(book.coverUrl).then(bytes => { if (live) setSrc(URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer], { type: 'image/jpeg' }))); }).catch(() => { if (live) setSrc(null); }); return () => { live = false; }; }, [runtime.api, book.coverUrl]);
|
||||
return <div style={{ aspectRatio: '2 / 3', overflow: 'hidden', borderRadius: 6, background: 'var(--mantine-color-gray-1)' }}>{src ? <img src={src} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <Center h="100%"><Text c="dimmed" ta="center" lineClamp={3}>{book.title || '无封面'}</Text></Center>}</div>;
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let objectUrl: string | null = null;
|
||||
if (!book.coverUrl) { setSrc(null); return; }
|
||||
void runtime.api.coverBytes(book.coverUrl).then(bytes => {
|
||||
if (!live) return;
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer], { type: 'image/jpeg' }));
|
||||
setSrc(objectUrl);
|
||||
}).catch(() => { if (live) setSrc(null); });
|
||||
return () => { live = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [runtime.api, book.coverUrl]);
|
||||
return <div className="cover">{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}<span className="format-badge">{book.format || '书籍'}</span></div>;
|
||||
}
|
||||
|
||||
export function LibraryPage() {
|
||||
const runtime = useRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const page = Math.max(1, Number(params.get('page')) || 1);
|
||||
const [search, setSearch] = useState(params.get('q') || '');
|
||||
const [queryText, setQueryText] = useState(params.get('q') || '');
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const query = useQuery({ queryKey: ['library-books', runtime.api.baseUrl, runtime.api.currentSession()?.user.id, page, queryText], queryFn: ({ signal }) => runtime.api.listBooks({ scope: 'library', page, pageSize: 48, search: queryText || '', sort: 'title', order: 'asc' }, { signal }) });
|
||||
const facets = useQuery({ queryKey: ['library-facets', runtime.api.baseUrl], queryFn: ({ signal }) => runtime.api.facets({ signal }), staleTime: 5 * 60_000 });
|
||||
const pages = query.data ? Math.max(1, Math.ceil(query.data.total / query.data.pageSize)) : 1;
|
||||
const searchSubmit = (event: FormEvent) => { event.preventDefault(); setQueryText(search.trim()); setParams({ ...(search.trim() ? { q: search.trim() } : {}), page: '1' }); };
|
||||
const toggleShelf = async (book: Book) => { setBusy(book.id); try { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, book.shelfState === 'on' ? 'remove' : 'add'); await queryClient.invalidateQueries({ queryKey: ['library-books'] }); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); } finally { setBusy(null); } };
|
||||
return <Stack p="md" maw={1440} mx="auto"><Group justify="space-between"><Title order={1}>书库</Title><Button component={Link} to="/shelf" variant="light">我的书架</Button></Group><Group align="end"><form onSubmit={searchSubmit} style={{ flex: '1 1 24rem' }}><TextInput label="搜索书库" value={search} onChange={event => setSearch(event.currentTarget.value)} placeholder="书名、作者、系列" /></form>{facets.data && <Text size="sm" c="dimmed">{facets.data.authors.length} 位作者 · {facets.data.series.length} 个系列 · {facets.data.formats.length} 种格式</Text>}</Group>{query.isPending && <Center py="xl"><Loader /></Center>}{query.error && <Alert color="red" title="书库加载失败">{query.error.message}</Alert>}{query.data && query.data.items.length === 0 && <Alert>没有匹配的书籍。</Alert>}{query.data && <SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 6, lg: 8 }}>{query.data.items.map(book => <Card key={book.id} withBorder padding="sm"><Stack gap="xs"><Link to={`/book/${encodeURIComponent(book.id)}`} style={{ textDecoration: 'none' }}><Cover book={book} /><Text fw={600} mt="xs" lineClamp={2}>{book.title || '未命名'}</Text><Text size="sm" c="dimmed" lineClamp={1}>{book.author || '未知作者'}</Text></Link><Button size="compact-sm" variant={book.shelfState === 'on' ? 'light' : 'filled'} loading={busy === book.id} onClick={() => void toggleShelf(book)}>{book.shelfState === 'on' ? '已在书架 · 移除' : '加入书架'}</Button></Stack></Card>)}</SimpleGrid>}{query.data && pages > 1 && <Pagination total={pages} value={page} onChange={value => setParams({ ...(queryText ? { q: queryText } : {}), page: String(value) })} />}</Stack>;
|
||||
function CollectionHeader({ title, subtitle, backTo, actions, className, footer }: { title: string; subtitle: string; backTo?: string; actions?: React.ReactNode; className?: string; footer?: React.ReactNode }) {
|
||||
return <header className={'collection-header' + (className ? ` ${className}` : '')}><div className="collection-heading">{backTo && <Button component={Link} to={backTo} className="icon-button" variant="subtle" aria-label="返回"><ArrowLeft size={20} /></Button>}<div className="collection-heading-text"><h1>{title}</h1><p className="muted">{subtitle}</p></div></div>{actions && <nav className="collection-links" aria-label="页面操作">{actions}</nav>}{footer}</header>;
|
||||
}
|
||||
|
||||
/** Folder-aware browsing page. The folder is URL state so links and back/forward remain shareable. */
|
||||
export function LibraryPage() { return <LibraryBrowsePage />; }
|
||||
|
||||
export function LibraryBrowsePage() {
|
||||
const runtime = useRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const path = params.get('path') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page')) || 1);
|
||||
const search = params.get('q') ?? '';
|
||||
const [draft, setDraft] = useState(search);
|
||||
const runtime = useRuntime(); const queryClient = useQueryClient(); const role = useAuthStore(state => state.verifiedUser?.role); const [params, setParams] = useSearchParams();
|
||||
const path = params.get('path') ?? ''; const page = Math.max(1, Number(params.get('page')) || 1); const search = params.get('q') ?? ''; const [draft, setDraft] = useState(search); const [busy, setBusy] = useState<string | null>(null);
|
||||
const listing = useQuery({ queryKey: ['library-browse', runtime.api.baseUrl, path, page], queryFn: ({ signal }) => runtime.api.browse(path, page, { signal }) });
|
||||
const books = useQuery({ queryKey: ['library-books', runtime.api.baseUrl, 'browse', path, page, search], queryFn: ({ signal }) => runtime.api.listBooks({ scope: 'library', path, page, pageSize: 60, search, sort: 'title', order: 'asc' }, { signal }) });
|
||||
const navigate = (nextPath: string, nextPage = 1, nextSearch = '') => setParams({ ...(nextPath ? { path: nextPath } : {}), ...(nextSearch ? { q: nextSearch } : {}), ...(nextPage > 1 ? { page: String(nextPage) } : {}) });
|
||||
const toggle = async (book: Book) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, book.shelfState === 'on' ? 'remove' : 'add'); await queryClient.invalidateQueries({ queryKey: ['library-books'] }); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); };
|
||||
const totalPages = books.data ? Math.max(1, Math.ceil(books.data.total / books.data.pageSize)) : 1;
|
||||
return <Stack p="md" maw={1440} mx="auto">
|
||||
<Group justify="space-between"><Title order={1}>书库</Title><Group><Button component={Link} to="/shelf" variant="light">我的书架</Button><Button component={Link} to="/library/files" variant="subtle">文件管理</Button></Group></Group>
|
||||
<Breadcrumbs>{(listing.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, i) => <Anchor key={`${crumb.path}-${i}`} component="button" onClick={() => navigate(crumb.path)}>{crumb.name}</Anchor>)}</Breadcrumbs>
|
||||
<Group align="end"><form style={{ flex: '1 1 24rem' }} onSubmit={e => { e.preventDefault(); navigate(path, 1, draft.trim()); }}><TextInput label="搜索当前文件夹" value={draft} onChange={e => setDraft(e.currentTarget.value)} /></form><Text size="sm" c="dimmed">{listing.data ? `${listing.data.dirs} 个文件夹 · ${listing.data.files} 个文件` : ''}</Text></Group>
|
||||
{listing.data?.entries.filter(e => e.type === 'dir').length ? <Card withBorder><Group>{listing.data.entries.filter(e => e.type === 'dir').map(entry => <Button key={entry.path} variant="light" onClick={() => navigate(entry.path)}>{entry.name}</Button>)}</Group></Card> : null}
|
||||
{books.isPending && <Center py="xl"><Loader /></Center>}{books.error && <Alert color="red">{books.error.message}</Alert>}
|
||||
{books.data && <SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 6, lg: 8 }}>{books.data.items.map(book => <Card key={book.id} withBorder padding="sm"><Stack gap="xs"><Link to={`/book/${encodeURIComponent(book.id)}`} style={{ textDecoration: 'none' }}><Cover book={book} /><Text fw={600} lineClamp={2}>{book.title || '未命名'}</Text><Text size="sm" c="dimmed" lineClamp={1}>{book.author || '未知作者'}</Text></Link><Button size="compact-sm" onClick={() => void toggle(book)}>{book.shelfState === 'on' ? '移出书架' : '加入书架'}</Button></Stack></Card>)}</SimpleGrid>}
|
||||
{totalPages > 1 && <Pagination total={totalPages} value={page} onChange={p => navigate(path, p, search)} />}
|
||||
</Stack>;
|
||||
const toggle = async (book: Book) => { setBusy(book.id); try { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'add'); await queryClient.invalidateQueries({ queryKey: ['library-books'] }); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); } finally { setBusy(null); } };
|
||||
const count = books.data?.total ?? 0; const pages = books.data ? Math.max(1, Math.ceil(count / books.data.pageSize)) : 1;
|
||||
const empty = !books.isPending && !books.error && books.data?.items.length === 0;
|
||||
return <div className="library-screen library-browse-screen">
|
||||
<CollectionHeader title="书库" subtitle="浏览本地书籍,发现下一本好书" backTo="/shelf" actions={role === 'admin' ? <Link className="button collection-link" to="/library/files"><Folder size={18} /><span>文件管理</span><span aria-hidden="true">›</span></Link> : undefined} />
|
||||
{listing.data && listing.data.crumbs.length > 1 && <div className="manager-crumbs library-path" aria-label="当前文件夹">{listing.data.crumbs.map((crumb, index) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" onClick={() => navigate(crumb.path)}>{crumb.name}</button></span>)}</div>}
|
||||
<main className="manager-body library-browse-body">
|
||||
<form className="shelf-search library-search" role="search" onSubmit={(event: FormEvent) => { event.preventDefault(); navigate(path, 1, draft.trim()); }}><Search className="search-glyph" size={18} aria-hidden="true" /><input type="search" aria-label="搜索书库" placeholder="搜索书名、作者" value={draft} onChange={event => setDraft(event.currentTarget.value)} />{draft && <button type="button" className="search-clear" aria-label="清除搜索" onClick={() => { setDraft(''); navigate(path); }}>×</button>}</form>
|
||||
{books.isPending && <FloatingNotice message="正在加载书库…" busy />}{books.error && <Alert color="red" title="书库加载失败">{books.error.message}</Alert>}
|
||||
{empty && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{search ? '没有匹配的书' : '书库还没有书'}</p><p className="muted">{search ? '换个关键词,或者检查一下作者名的写法。' : '前往文件管理添加书籍,再回到这里浏览。'}</p><div className="empty-actions">{search ? <button type="button" className="button" onClick={() => { setDraft(''); navigate(path); }}>清除搜索</button> : role === 'admin' ? <Link className="button primary" to="/library/files"><Folder size={16} />文件管理</Link> : null}</div></div>}
|
||||
{books.data && books.data.items.length > 0 && <div className="book-grid library-browse-grid" data-density="cozy" data-show-author="true">{books.data.items.map(book => <div className="book-card is-row" key={book.id}><Link className="book-open" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`${book.title} ${book.author ?? ''}`}><Cover book={book} /><div className="title">{book.title || '未命名'}</div><div className="author">{book.author || '未知作者'}</div></Link>{book.shelfState === 'off' && <button type="button" className="book-shelve" aria-label={`把${book.title}加入书架`} disabled={busy === book.id} onClick={() => void toggle(book)}>+ 加入书架</button>}</div>)}</div>}
|
||||
{books.data && <div className="manager-status muted">{search ? `找到 ${count} 本` : `${count} 本 · 第 ${page} / ${pages} 页`}</div>}
|
||||
{pages > 1 && <nav className="shelf-pager" aria-label="翻页"><button className="pager-step" type="button" disabled={page <= 1} onClick={() => navigate(path, page - 1, search)}>上一页</button><span>{page} / {pages}</span><button className="pager-step" type="button" disabled={page >= pages} onClick={() => navigate(path, page + 1, search)}>下一页</button></nav>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function entryMeta(entry: BrowseEntry): React.ReactNode[] {
|
||||
const result: React.ReactNode[] = [];
|
||||
if (entry.type === 'file') {
|
||||
if (entry.size > 0) result.push(<span key="size">{formatBytes(entry.size)}</span>);
|
||||
if (entry.mtime > 0) result.push(<span key="mtime">{formatDate(entry.mtime)}</span>);
|
||||
}
|
||||
if (entry.scanned) result.push(<span className="manager-badge" key="book">书籍</span>);
|
||||
if (entry.hiddenByRule) result.push(<span className="manager-badge warn" key="hidden">扫描忽略</span>);
|
||||
if (entry.shelfState === 'off') result.push(<span className="manager-badge off" key="shelf">不在书架</span>);
|
||||
if (entry.type === 'file' && entry.ext && !entry.scanned) result.push(<span key="ext">{entry.ext}</span>);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Administrator-only file manager. All mutations are explicit and scoped to selected paths. */
|
||||
export function LibraryFilesPage() {
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
if (role !== 'admin') return <Stack p="md"><Alert color="red" title="无权访问">文件管理需要管理员权限。</Alert><Button component={Link} to="/library">返回书库</Button></Stack>;
|
||||
return <LibraryFilesAdminPage />;
|
||||
const role = useAuthStore(state => state.verifiedUser?.role); if (role !== 'admin') return <div className="library-screen"><div className="empty-state collection-empty"><p>无权访问</p><p className="muted">文件管理需要管理员权限。</p><Link className="button" to="/library">返回书库</Link></div></div>; return <LibraryFilesAdminPage />;
|
||||
}
|
||||
|
||||
function LibraryFilesAdminPage() {
|
||||
const runtime = useRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const path = params.get('path') ?? '';
|
||||
const page = Math.max(1, Number(params.get('page')) || 1);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const runtime = useRuntime(); const queryClient = useQueryClient(); const [params, setParams] = useSearchParams(); const path = params.get('path') ?? ''; const page = Math.max(1, Number(params.get('page')) || 1); const [selected, setSelected] = useState<string[]>([]); const [dialog, setDialog] = useState<Dialog | null>(null); const [feedback, setFeedback] = useState<{ text: string; error?: boolean } | null>(null);
|
||||
const query = useQuery({ queryKey: ['library-files', runtime.api.baseUrl, path, page], queryFn: ({ signal }) => runtime.api.browse(path, page, { signal }) });
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
const go = (next: string, p = 1) => setParams({ ...(next ? { path: next } : {}), ...(p > 1 ? { page: String(p) } : {}) });
|
||||
const mutate = async (action: () => Promise<unknown>) => { await action(); setSelected([]); await refresh(); };
|
||||
const rename = async (entry: BrowseEntry) => { const name = window.prompt('重命名', entry.name)?.trim(); if (name && name !== entry.name) await mutate(() => runtime.api.browseRename(entry.path, name)); };
|
||||
const move = async () => { const target = window.prompt('移动到(书库相对路径)', path); if (target !== null && selected.length) await mutate(() => runtime.api.browseMove(selected, target.trim())); };
|
||||
const remove = async () => { if (selected.length && window.confirm(`删除 ${selected.length} 项?`)) await mutate(() => runtime.api.browseDelete(selected)); };
|
||||
const mkdir = async () => { const name = window.prompt('新建文件夹'); if (name?.trim()) await mutate(() => runtime.api.browseMkdir(path, name.trim())); };
|
||||
const upload = async (files: File[]) => { if (!files.length) return; await mutate(() => runtime.api.upload(files, path, 'rename')); };
|
||||
const go = (next: string, p = 1) => setParams({ ...(next ? { path: next } : {}), ...(p > 1 ? { page: String(p) } : {}) }); const refresh = () => queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
const mutate = async (action: () => Promise<unknown>) => { setFeedback(null); try { await action(); setSelected([]); await refresh(); setFeedback({ text: '操作已完成' }); } catch (reason) { setFeedback({ text: reason instanceof Error ? reason.message : '操作失败,请重试', error: true }); } };
|
||||
const prompt = (title: string, value = '') => new Promise<string | null>(resolve => setDialog({ kind: 'prompt', title, value, resolve }));
|
||||
const confirm = (title: string, body: string) => new Promise<boolean>(resolve => setDialog({ kind: 'confirm', title, body, resolve }));
|
||||
const rename = async (entry: BrowseEntry) => { const name = (await prompt('重命名', entry.name))?.trim(); if (name && name !== entry.name) await mutate(() => runtime.api.browseRename(entry.path, name)); };
|
||||
const mkdir = async () => { const name = (await prompt('新建文件夹'))?.trim(); if (name) await mutate(() => runtime.api.browseMkdir(path, name)); };
|
||||
const move = async (paths = selected) => { const target = await prompt('移动到(书库相对路径)', path); if (target !== null && paths.length) await mutate(() => runtime.api.browseMove(paths, target.trim())); };
|
||||
const remove = async (paths = selected) => { if (paths.length && await confirm('删除所选内容', `确定要删除 ${paths.length} 项吗?`)) await mutate(() => runtime.api.browseDelete(paths)); };
|
||||
const pages = query.data ? Math.max(1, Math.ceil(query.data.total / 200)) : 1;
|
||||
return <Stack p="md" maw={1200} mx="auto"><Group justify="space-between"><Title order={1}>文件管理</Title><Group><Button component={Link} to="/library" variant="light">返回书库</Button><Button onClick={mkdir} disabled={!query.data?.writable}>新建文件夹</Button><Button component="label" disabled={!query.data?.writable}>上传<input hidden type="file" multiple onChange={e => void upload(Array.from(e.currentTarget.files ?? []))} /></Button></Group></Group>
|
||||
<Breadcrumbs>{(query.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, i) => <Anchor key={`${crumb.path}-${i}`} component="button" onClick={() => go(crumb.path)}>{crumb.name}</Anchor>)}</Breadcrumbs>
|
||||
{selected.length > 0 && <Group><Badge>已选 {selected.length} 项</Badge><Button size="compact-sm" onClick={() => void move()}>移动</Button><Button size="compact-sm" color="red" onClick={() => void remove()}>删除</Button><Button size="compact-sm" variant="subtle" onClick={() => setSelected([])}>清除</Button></Group>}
|
||||
<Divider />
|
||||
{query.isPending && <Center py="xl"><Loader /></Center>}{query.error && <Alert color="red">{query.error.message}</Alert>}
|
||||
{query.data && <Table striped highlightOnHover><Table.Thead><Table.Tr><Table.Th><Checkbox aria-label="全选" checked={selected.length > 0 && selected.length === query.data.entries.length} onChange={e => setSelected(e.currentTarget.checked ? query.data!.entries.map(x => x.path) : [])} /></Table.Th><Table.Th>名称</Table.Th><Table.Th>类型</Table.Th><Table.Th>大小</Table.Th><Table.Th /></Table.Tr></Table.Thead><Table.Tbody>{query.data.entries.map(entry => <Table.Tr key={entry.path}><Table.Td><Checkbox checked={selected.includes(entry.path)} onChange={() => setSelected(s => s.includes(entry.path) ? s.filter(x => x !== entry.path) : [...s, entry.path])} /></Table.Td><Table.Td>{entry.type === 'dir' ? <Anchor component="button" onClick={() => go(entry.path)}>{entry.name}</Anchor> : entry.name}</Table.Td><Table.Td>{entry.type === 'dir' ? '文件夹' : entry.ext || '文件'}</Table.Td><Table.Td>{entry.size ? `${Math.round(entry.size / 1024)} KB` : '-'}</Table.Td><Table.Td><Menu><Menu.Target><ActionIcon variant="subtle">⋯</ActionIcon></Menu.Target><Menu.Dropdown><Menu.Item onClick={() => void rename(entry)}>重命名</Menu.Item><Menu.Item onClick={() => setSelected([entry.path])}>选择</Menu.Item></Menu.Dropdown></Menu></Table.Td></Table.Tr>)}</Table.Tbody></Table>}
|
||||
{pages > 1 && <Pagination total={pages} value={page} onChange={p => go(path, p)} />}
|
||||
</Stack>;
|
||||
const crumbs = <div className="manager-crumbs">{(query.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, index, all) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" aria-current={index === all.length - 1 ? 'true' : undefined} onClick={() => go(crumb.path)}>{crumb.name}</button></span>)}</div>;
|
||||
return <div className="library-screen library-files-screen"><CollectionHeader className="file-manager-header" title="文件管理" subtitle="管理书库中的文件与文件夹" backTo="/library" actions={<><label className={'button collection-link' + (!query.data?.writable ? ' is-disabled' : '')}><Upload size={18} /><span>上传书籍</span><input hidden type="file" multiple disabled={!query.data?.writable} onChange={event => { const files = Array.from(event.currentTarget.files ?? []); if (files.length) void mutate(() => runtime.api.upload(files, path, 'rename')); event.currentTarget.value = ''; }} /></label><button type="button" className="button collection-link" disabled={!query.data?.writable} onClick={mkdir}><Plus size={18} /><span>新建文件夹</span></button></>} footer={crumbs} />
|
||||
<main className="manager-body">
|
||||
{selected.length > 0 && <div className="manager-actions"><Badge>已选 {selected.length} 项</Badge><button type="button" className="button" onClick={() => void move()}>移动</button><button type="button" className="button danger" onClick={() => void remove()}>删除</button><button type="button" className="button" onClick={() => setSelected([])}>清除</button></div>}
|
||||
{feedback && <Alert color={feedback.error ? 'red' : 'green'} withCloseButton onClose={() => setFeedback(null)}>{feedback.text}</Alert>}
|
||||
{query.isPending && <FloatingNotice message="正在读取文件列表…" busy />}{query.error && <Alert color="red">{query.error.message}</Alert>}
|
||||
{query.data && <div className={'manager-list' + (selected.length ? ' selection-mode' : '')}>{query.data.entries.length === 0 ? <div className="empty-state collection-empty"><Folder className="empty-glyph" aria-hidden="true" /><p>这个文件夹是空的</p><p className="muted">{query.data.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p></div> : query.data.entries.map(entry => { const checked = selected.includes(entry.path); return <div className="manager-row" data-type={entry.type} data-selected={checked ? 'true' : undefined} key={entry.path} tabIndex={0}><button type="button" className="manager-check" role="checkbox" aria-label={`选择${entry.name}`} aria-checked={checked} onClick={event => { event.stopPropagation(); setSelected(items => items.includes(entry.path) ? items.filter(item => item !== entry.path) : [...items, entry.path]); }} /><div className="manager-name"><span className="manager-icon">{entry.type === 'dir' ? <Folder size={20} /> : <FileText size={20} />}</span><span className="manager-label">{entry.type === 'dir' ? <button type="button" className="manager-crumb" onClick={() => go(entry.path)}>{entry.name}</button> : entry.name}</span></div><div className="manager-meta muted">{entryMeta(entry)}</div><Menu><Menu.Target><ActionIcon className="manager-more" variant="subtle" aria-label={`${entry.name} 的操作`}><MoreHorizontal size={19} /></ActionIcon></Menu.Target><Menu.Dropdown className="library-file-menu"><Menu.Item disabled={!query.data.writable} onClick={() => void rename(entry)}>重命名</Menu.Item><Menu.Item onClick={() => setSelected([entry.path])}>选择</Menu.Item><Menu.Item disabled={!query.data.writable} onClick={() => void move([entry.path])}>移动…</Menu.Item><Menu.Item disabled={!query.data.writable} color="red" onClick={() => void remove([entry.path])}>删除</Menu.Item></Menu.Dropdown></Menu></div>; })}</div>}
|
||||
{query.data && <div className="manager-status muted">{query.data.dirs} 个文件夹 · {query.data.files} 个文件 · {formatBytes(query.data.size)}</div>}
|
||||
{pages > 1 && <nav className="manager-pager shelf-pager"><button className="pager-step" type="button" disabled={page <= 1} onClick={() => go(path, page - 1)}>上一页</button><span>{page} / {pages}</span><button className="pager-step" type="button" disabled={page >= pages} onClick={() => go(path, page + 1)}>下一页</button></nav>}
|
||||
</main>{dialog && <DialogView dialog={dialog} destructive="删除" onClose={answer => { const current = dialog; setDialog(null); current.resolve(answer as never); }} />}</div>;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@ import { MediaCover } from './cover.tsx';
|
||||
import type { Item, MediaChannel } from '../api/media-api.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { favoriteChannel } from './favorites.tsx';
|
||||
import { itemLabel } from './item-label.ts';
|
||||
|
||||
export function MediaItemGrid({ items, channel }: { items: Item[]; channel?: MediaChannel }) {
|
||||
const { mediaApi } = useRuntime();
|
||||
if (!items.length) return <Text className="media-empty" c="dimmed">暂无内容。</Text>;
|
||||
return <div className="media-grid">
|
||||
{items.map(item => <UnstyledButton className="media-tile" key={item.id} component={Link} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
<MediaCover api={mediaApi} item={item} square={['album', 'artist', 'track'].includes(item.kind)} />
|
||||
<strong>{item.title}</strong>
|
||||
</UnstyledButton>)}
|
||||
{items.map(item => {
|
||||
const subtitle = itemLabel(item);
|
||||
return <UnstyledButton className="media-tile" key={item.id} component={Link} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
|
||||
<MediaCover api={mediaApi} item={item} square={['album', 'artist', 'track'].includes(item.kind)} />
|
||||
<strong title={item.title}>{item.title}</strong>
|
||||
{subtitle && <small title={subtitle}>{subtitle}</small>}
|
||||
</UnstyledButton>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Alert, Button, Group, Loader, Title, UnstyledButton } from '@mantine/core';
|
||||
import { Alert, Button, Title, UnstyledButton } from '@mantine/core';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMediaChannel } from '../hooks/use-media-channel.ts';
|
||||
import { ChannelSwitcher } from './channel-navigation.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function MediaPageFrame({ title, subtitle, children, actions, className, backTo, backLabel }: { title: string; subtitle?: ReactNode; children: ReactNode; actions?: ReactNode; className?: string; backTo?: string; backLabel?: string }) {
|
||||
export function MediaPageFrame({ title, subtitle, children, actions, className, backTo, backLabel, hideHeader = false }: { title: string; subtitle?: ReactNode; children: ReactNode; actions?: ReactNode; className?: string; backTo?: string; backLabel?: string; hideHeader?: boolean }) {
|
||||
const channel = useMediaChannel();
|
||||
const location = useLocation();
|
||||
const stateReturn = typeof location.state?.returnTo === 'string' && location.state.returnTo.startsWith('/media/') ? location.state.returnTo : undefined;
|
||||
@@ -14,21 +15,21 @@ export function MediaPageFrame({ title, subtitle, children, actions, className,
|
||||
const rootClassName = ['media-screen', 'media-page', className].filter(Boolean).join(' ');
|
||||
const catalog = className?.split(/\s+/).includes('media-catalog-page') ?? false;
|
||||
return <div className={rootClassName}>
|
||||
<header className="media-heading">
|
||||
{!hideHeader && <header className="media-heading">
|
||||
{catalog
|
||||
? <div className="media-mobile-title"><Title order={1}>{title}</Title><ChannelSwitcher current={channel} /></div>
|
||||
? <span className="media-mobile-title"><ChannelSwitcher current={channel} label={title} /></span>
|
||||
: <div className="media-page-heading">
|
||||
<UnstyledButton className="media-back-button" component={Link} to={target} aria-label={label} title={label}><ChevronLeft size={20} aria-hidden="true" /></UnstyledButton>
|
||||
<UnstyledButton className="media-back-button" component={Link} to={target} aria-label="← 返回" title={label}><ChevronLeft size={20} aria-hidden="true" /></UnstyledButton>
|
||||
<Title order={1}>{title}</Title>
|
||||
{subtitle && <span className="media-heading-context">{subtitle}</span>}
|
||||
</div>}
|
||||
{actions && <div className="media-heading-actions">{actions}</div>}
|
||||
</header>
|
||||
<div id={className?.split(/\s+/).includes('media-secondary-page') ? 'media-heading-actions-host' : undefined} className="media-heading-actions">{actions}</div>
|
||||
</header>}
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
export function QueryFeedback({ pending, error, retry }: { pending?: boolean; error?: Error | null; retry?: () => void }) {
|
||||
if (pending) return <Group role="status"><Loader size="sm" /><span>正在加载…</span></Group>;
|
||||
if (pending) return <FloatingNotice message="正在加载…" busy />;
|
||||
if (error) return <Alert color="red" title="加载失败" role="alert">{error.message}{retry && <Button variant="subtle" onClick={retry}>重试</Button>}</Alert>;
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ChevronDown,ChevronRight,Trash2} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type {AiScanBatch,AiScanJob,Library,MediaApi} from '../api/media-api.ts';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
|
||||
const labels:Record<string,string>={queued:'排队中',running:'进行中',complete:'已完成',failed:'失败'};
|
||||
const category:Record<string,string>={movie:'电影',series:'剧集',music:'音乐',audiobook:'有声书',other:'其他'};
|
||||
@@ -10,8 +11,8 @@ function Batch({batch}:{batch:AiScanBatch}){
|
||||
return <article className="media-ai-batch"><button className="media-ai-batch-toggle" onClick={()=>setOpen(!open)}>{open?<ChevronDown size={16}/>:<ChevronRight size={16}/>}第 {batch.batch} 批 · {batch.pathCount} 条路径 · {batch.state==='complete'?'已完成':batch.state==='failed'?'失败':'进行中'}</button>{open&&<div className="media-ai-batch-detail"><p>路径:{batch.paths.join('、')}</p>{batch.error&&<p className="media-error">{batch.error}</p>}{batch.raw&&<details open><summary>AI 原始返回</summary><pre>{batch.raw}</pre></details>}<details><summary>解析结果({batch.items.length} 条)</summary><pre>{JSON.stringify(batch.items,(key,value)=>key==='category'?category[value]??value:value,2)}</pre></details></div>}</article>;
|
||||
}
|
||||
export function AiScanJobs({api,jobs,libraries,busy,onRefresh,onDelete}:{api:MediaApi;jobs:AiScanJob[];libraries:Library[];busy:boolean;onRefresh:()=>void;onDelete:(id:string)=>void}){
|
||||
const [selected,setSelected]=useState<string|null>(null),[batches,setBatches]=useState<AiScanBatch[]>([]);
|
||||
useEffect(()=>{if(!selected){setBatches([]);return;}let cancelled=false;void api.aiScanBatches(selected).then(result=>{if(!cancelled)setBatches(result.items);});return()=>{cancelled=true;};},[api,selected,jobs]);
|
||||
const [selected,setSelected]=useState<string|null>(null),[batches,setBatches]=useState<AiScanBatch[]>([]),[batchesLoading,setBatchesLoading]=useState(false);
|
||||
useEffect(()=>{if(!selected){setBatches([]);setBatchesLoading(false);return;}let cancelled=false;setBatches([]);setBatchesLoading(true);void api.aiScanBatches(selected).then(result=>{if(!cancelled)setBatches(result.items);}).finally(()=>{if(!cancelled)setBatchesLoading(false);});return()=>{cancelled=true;};},[api,selected,jobs]);
|
||||
useEffect(()=>{if(!jobs.some(job=>job.state==='queued'||job.state==='running'))return;const timer=setInterval(onRefresh,2500);return()=>clearInterval(timer);},[jobs,onRefresh]);
|
||||
return <section className="media-ai-jobs">{!jobs.length&&<p>暂无 AI 扫描任务。</p>}{jobs.map(job=><article className={'media-scan-job'+(job.state==='running'?' is-running':'')} key={job.id}><div className="media-task-copy"><strong>{libraries.find(l=>l.id===job.libraryId)?.name??job.libraryId} · {job.path||'整个库'}</strong><small>{job.processedPaths}/{job.totalPaths} 条路径 · {job.resultCount} 条结果 · 耗时 {elapsed(job)}</small></div><span className="media-task-state" data-state={job.state}>{labels[job.state]}</span>{!['queued','running'].includes(job.state)&&<button className="media-task-icon" disabled={busy} onClick={()=>onDelete(job.id)} aria-label="删除 AI 扫描历史" title="删除 AI 扫描历史"><Trash2 size={17} aria-hidden="true"/></button>}{['queued','running'].includes(job.state)&&<span className="media-task-icon-placeholder"/>}<div className="media-ai-job-actions"><button disabled={busy} onClick={()=>setSelected(selected===job.id?null:job.id)}>{selected===job.id?'收起明细':'查看明细'}</button></div>{job.error&&<p className="media-error">{job.error}</p>}{selected===job.id&&<div className="media-ai-batches">{batches.length?batches.map(batch=><Batch batch={batch} key={batch.id}/>):<p>正在读取批次明细…</p>}</div>}</article>)}</section>;
|
||||
return <section className="media-ai-jobs">{!jobs.length&&<p>暂无 AI 扫描任务。</p>}{jobs.map(job=><article className={'media-scan-job'+(job.state==='running'?' is-running':'')} key={job.id}><div className="media-task-copy"><strong>{libraries.find(l=>l.id===job.libraryId)?.name??job.libraryId} · {job.path||'整个库'}</strong><small>{job.processedPaths}/{job.totalPaths} 条路径 · {job.resultCount} 条结果 · 耗时 {elapsed(job)}</small></div><span className="media-task-state" data-state={job.state}>{labels[job.state]}</span>{!['queued','running'].includes(job.state)&&<button className="media-task-icon" disabled={busy} onClick={()=>onDelete(job.id)} aria-label="删除 AI 扫描历史" title="删除 AI 扫描历史"><Trash2 size={17} aria-hidden="true"/></button>}{['queued','running'].includes(job.state)&&<span className="media-task-icon-placeholder"/>}<div className="media-ai-job-actions"><button disabled={busy} onClick={()=>setSelected(selected===job.id?null:job.id)}>{selected===job.id?'收起明细':'查看明细'}</button></div>{job.error&&<p className="media-error">{job.error}</p>}{selected===job.id&&<div className="media-ai-batches">{batchesLoading?<FloatingNotice message="正在读取批次明细…" busy/>:batches.length?batches.map(batch=><Batch batch={batch} key={batch.id}/>):<p>暂无批次明细。</p>}</div>}</article>)}</section>;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import {Check,ChevronRight,Heart,SlidersHorizontal} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {Modal} from '../../../ui/modal.tsx';
|
||||
import {MediaCover} from './cover.tsx';
|
||||
import {itemLabel} from './item-label.ts';
|
||||
import type {Item,MediaApi,MediaChannel} from '../api/media-api.ts';
|
||||
@@ -16,11 +14,8 @@ export function MediaFavorites({api,items,total,offset,scope,busy,onScope,onPage
|
||||
api:MediaApi;items:Item[];total:number;offset:number;scope:FavoriteScope;busy:boolean;
|
||||
onScope:(scope:FavoriteScope)=>void;onPage:(offset:number)=>void;onOpen:(item:Item)=>void;
|
||||
}){
|
||||
const [filterOpen,setFilterOpen]=useState(false);
|
||||
return <section className="media-favorites" aria-label="收藏列表" onClick={event=>{
|
||||
if(event.target instanceof HTMLDialogElement){const rect=event.target.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)setFilterOpen(false);}
|
||||
}}>
|
||||
<div className="media-favorite-tools"><p>{total} 项收藏{scope!=='all'?' · '+scopes.find(([value])=>value===scope)![1]:''}</p><button type="button" aria-label="筛选收藏" aria-haspopup="dialog" onClick={()=>setFilterOpen(true)}><SlidersHorizontal size={18} aria-hidden="true"/>筛选</button></div>
|
||||
return <section className="media-favorites" aria-label="收藏列表">
|
||||
<div className="media-favorite-tools"><p>{total} 项收藏{scope!=='all'?' · '+scopes.find(([value])=>value===scope)![1]:''}</p><details className="media-favorite-filter-menu"><summary aria-label="筛选收藏"><SlidersHorizontal size={16} strokeWidth={1.8} aria-hidden="true"/><span>筛选</span></summary><div className="media-favorite-filter-options" role="listbox" aria-label="收藏类型">{scopes.map(([value,label])=><button key={value} type="button" role="option" aria-selected={scope===value} onClick={event=>{event.currentTarget.closest('details')?.removeAttribute('open');if(scope!==value)onScope(value);}}><span>{label}</span>{scope===value&&<Check size={16} strokeWidth={1.9} aria-hidden="true"/>}</button>)}</div></details></div>
|
||||
<div className="media-favorite-list">{items.map(item=><button type="button" key={item.id} className="media-favorite-row" onClick={()=>onOpen(item)}>
|
||||
<MediaCover api={api} item={item} square={favoriteChannel(item)==='music'}/>
|
||||
<span className="media-favorite-copy"><strong title={item.title}>{item.title}</strong><small>{[kindNames[item.kind],itemLabel(item)].filter(Boolean).join(' · ')}</small></span>
|
||||
@@ -28,6 +23,5 @@ export function MediaFavorites({api,items,total,offset,scope,busy,onScope,onPage
|
||||
</button>)}</div>
|
||||
{!items.length&&<div className="media-personal-empty"><Heart size={32} strokeWidth={1.4} aria-hidden="true"/><p>{scope==='all'?'还没有收藏,打开作品详情即可收藏。':'暂无这类收藏。'}</p>{scope!=='all'&&<button onClick={()=>onScope('all')}>查看全部收藏</button>}</div>}
|
||||
{total>60&&<nav className="media-toolbar" aria-label="收藏分页"><button disabled={busy||offset===0} onClick={()=>onPage(Math.max(0,offset-60))}>上一页</button><span>第 {Math.floor(offset/60)+1} 页</span><button disabled={busy||offset+60>=total} onClick={()=>onPage(offset+60)}>下一页</button></nav>}
|
||||
{filterOpen&&<Modal className="media-modal" title="收藏类型" busy={false} onClose={()=>setFilterOpen(false)}><div className="media-favorite-filter" role="group" aria-label="收藏类型">{scopes.map(([value,label])=><button key={value} type="button" aria-pressed={scope===value} onClick={()=>{setFilterOpen(false);if(scope!==value)onScope(value);}}><span>{label}</span>{scope===value&&<Check size={18} aria-hidden="true"/>}</button>)}</div></Modal>}
|
||||
</section>;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import {FolderRecognition} from './folder-recognition.tsx';
|
||||
import {FolderCleanup} from './folder-cleanup.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight} from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight,Ellipsis} from 'lucide-react';
|
||||
import type {Detail,MediaApi,Part} from '../api/media-api.ts';
|
||||
import {EditionDetails,type ChapterPosition} from './edition-details.tsx';
|
||||
import {ResourceInfo} from './resource-info.tsx';
|
||||
@@ -20,7 +21,7 @@ function FileWork({api,item,busy,selected,onSelect,positions,onPosition,onPlay,o
|
||||
{edition&&<EditionDetails key={edition.id} api={api} item={item} edition={edition} busy={busy} showTools={false} position={positions[edition.id]??{query:'',page:0}} onPositionChange={position=>onPosition(edition.id,position)} onPlay={onPlay} onQueue={onQueue} onRefresh={onRefresh} onChooseVersion={onDetail}/>}
|
||||
</section>;
|
||||
}
|
||||
export function MediaFolders({api,admin=false,video=false,libraryId,initialLocation,onLocationChange,onPlay,onQueue,onDetail}:{api:MediaApi;admin?:boolean;video?:boolean;libraryId:string;initialLocation?:FolderLocation|undefined;onLocationChange?:(location:FolderLocation)=>void;onPlay:(parts:Part[],index:number,title:string)=>Promise<void>;onQueue:(ids:string[])=>Promise<void>;onDetail:(id:string,location:FolderLocation)=>void}){
|
||||
export function MediaFolders({api,admin=false,video=false,libraryId,initialLocation,onLocationChange,onPlay,onQueue,onDetail,actionsHost}:{api:MediaApi;admin?:boolean;video?:boolean;libraryId:string;initialLocation?:FolderLocation|undefined;onLocationChange?:(location:FolderLocation)=>void;onPlay:(parts:Part[],index:number,title:string)=>Promise<void>;onQueue:(ids:string[])=>Promise<void>;onDetail:(id:string,location:FolderLocation)=>void;actionsHost?:HTMLElement|null}){
|
||||
const [path,setPath]=useState(initialLocation?.path??''),[offset,setOffset]=useState(initialLocation?.offset??0),[assetId,setAssetId]=useState<string|null>(initialLocation?.assetId??null),[retry,setRetry]=useState(0);
|
||||
const [editions,setEditions]=useState(initialLocation?.editions??{}),[chapters,setChapters]=useState(initialLocation?.chapters??{});
|
||||
const [listing,setListing]=useState<Listing|null>(null),[file,setFile]=useState<FileDetail|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[notice,setNotice]=useState('');
|
||||
@@ -39,13 +40,16 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat
|
||||
useEffect(()=>{onLocationChange?.({path,offset,assetId,editions,chapters});},[path,offset,assetId]);
|
||||
function open(next:string){setCleanupNotice('');setPath(next);setOffset(0);setAssetId(null);}
|
||||
async function action(run:()=>Promise<void>,queue=false){if(busy)return;const current=generation.current;setBusy(true);setActionError('');setNotice('');try{await run();if(current===generation.current)setNotice(queue?'已加入队列':'');}catch(error){if(current===generation.current)setActionError((error instanceof ApiError&&error.kind==='offline'?'无法连接服务器':error instanceof Error?error.message:'操作失败')+(queue?'。加入结果尚未确认,请先到待播队列核对,再决定是否重新添加。':'。可以重新选择章节播放。'));}finally{if(current===generation.current)setBusy(false);}}
|
||||
const managementActions = admin && listing ? <div className="media-folder-actions" aria-label="文件管理操作">
|
||||
const managementActions = admin && listing ? <details className="media-actions media-folder-actions" aria-label="文件管理操作">
|
||||
<summary aria-label="文件管理操作" title="文件管理操作"><Ellipsis size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="文件管理操作">
|
||||
{video && <FolderRecognition key={libraryId+'-rules-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}
|
||||
<FolderCleanup key={libraryId+'-cleanup-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>
|
||||
</div> : null;
|
||||
<FolderCleanup key={libraryId+'-cleanup-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} menuOnly onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>
|
||||
</nav>
|
||||
</details> : null;
|
||||
const segments=path?path.split('/'):[];
|
||||
return <section ref={folderRoot} className="media-folders" aria-label="影音文件夹">
|
||||
{managementActions}
|
||||
{actionsHost && managementActions ? createPortal(managementActions,actionsHost) : managementActions}
|
||||
<nav className="media-folder-path" aria-label="目录位置"><button disabled={busy} onClick={()=>open('')}>库内根目录</button>{segments.map((segment,index)=><button key={index} disabled={busy} onClick={()=>open(segments.slice(0,index+1).join('/'))}>/ {segment}</button>)}</nav>
|
||||
{(path||assetId)&&<button className="media-folder-back" disabled={busy} aria-label={'← '+(assetId?'返回文件列表':'上级目录')} onClick={()=>assetId?setAssetId(null):open(segments.slice(0,-1).join('/'))}><ChevronLeft size={16} aria-hidden="true"/>{assetId?'返回文件列表':'上级目录'}</button>}
|
||||
{error&&<MediaScreenError fullPage error={cause} message={error} busy={loading} retryLabel="重新加载" onRetry={()=>setRetry(value=>value+1)}/>}{actionError&&<div className="media-error" role="alert">{actionError}</div>}{notice&&<p role="status">{notice}</p>}
|
||||
|
||||
@@ -2,14 +2,14 @@ import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Library, MediaApi, MediaChannel } from '../api/media-api.ts';
|
||||
import { newId } from '../../../core/id.ts';
|
||||
import {ChevronLeft} from 'lucide-react';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
|
||||
const labels = { video: '影视', music: '音乐', audiobook: '有声书' };
|
||||
export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,onCancel }: {
|
||||
export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,onCancel, hideHeader = false }: {
|
||||
api: MediaApi; channel: MediaChannel; disabled: boolean;
|
||||
onCreated: (library: Library) => void; onJobs: (id: string) => Promise<void>;
|
||||
onCancel?:()=>void;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
const [kind,setKind]=useState<MediaChannel>(channel),[dirty,setDirty]=useState(false),[leaving,setLeaving]=useState(false);
|
||||
const [storage,setStorage]=useState<'local'|'openlist'>('local');
|
||||
@@ -36,7 +36,7 @@ export function MediaLibraryCreate({ api, channel, disabled, onCreated, onJobs,o
|
||||
await onJobs(library.id);
|
||||
};
|
||||
return <section className="media-library-create">
|
||||
{onCancel?<header className="media-settings-heading"><button className="media-back-button" data-media-back aria-label="← 返回内容" disabled={busy||disabled} onClick={close}><ChevronLeft size={20} aria-hidden="true"/></button><h1>新建媒体库</h1></header>:<h2>新建{labels[channel]}媒体库</h2>}
|
||||
{!hideHeader && <h2>新建{labels[channel]}媒体库</h2>}
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃新建媒体库?" text="媒体库尚未创建,离开后将丢弃已填写的内容。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel?.();}}/>}
|
||||
{error && <p className="media-error" role="alert">{error}</p>}
|
||||
{created ? <>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Library, LibraryConfiguration, MediaApi } from '../api/media-api.ts';
|
||||
import {ChevronLeft} from 'lucide-react';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
type Configuration = LibraryConfiguration;
|
||||
const kinds = { video: '影视', music: '音乐', audiobook: '有声书' };
|
||||
@@ -51,7 +52,7 @@ export function MediaLibraryEditor({ api, library, onSaved, onCancel }: {
|
||||
{leaving&&<FloatingConfirm theme="media" title="放弃媒体库修改?" text="媒体库修改尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑" onCancel={()=>setLeaving(false)} onConfirm={()=>{setLeaving(false);onCancel();}}/>}
|
||||
<form className="media-form media-library-editor" aria-label="媒体库配置" onSubmit={event => { event.preventDefault(); void save(); }}>
|
||||
{error && <div role="alert" className="media-error">{error}{!configuration && <button type="button" onClick={() => setAttempt(value => value + 1)}>重试读取配置</button>}</div>}
|
||||
{!configuration && !error && <p role="status">正在读取媒体库配置…</p>}
|
||||
{!configuration && !error && <FloatingNotice message="正在读取媒体库配置…" busy />}
|
||||
<label>媒体库名称<input name="name" required maxLength={200} value={name} disabled={!configuration || saving} onInput={event => setName(event.currentTarget.value)}/></label>
|
||||
{configuration && <>
|
||||
<div className="media-library-fields"><div><span>内容类型</span><p className="media-readonly-field">{kinds[configuration.kind]}</p></div><div><span>接入方式</span><p className="media-readonly-field">{configuration.storage==='openlist'?'OpenList':'服务器目录'}</p></div></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Ellipsis,Folder } from 'lucide-react';
|
||||
import type { Library,MediaApi } from '../api/media-api.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
interface Summary {id:string;root:string;counts:Record<string,number>;chapters:number;missingFiles:number}
|
||||
export interface LibraryListPosition {query:string;page:number}
|
||||
const labels={video:'影视',music:'音乐',audiobook:'有声书'};
|
||||
@@ -14,7 +15,7 @@ export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,
|
||||
const last=Math.max(0,Math.ceil(filtered.length/30)-1),page=Math.min(position.page,last);
|
||||
return <section aria-label="全部媒体库">
|
||||
{(libraries.length>10||position.query)&&<div className="media-library-find"><label>查找媒体库<input type="search" value={position.query} placeholder="名称、类型或目录" onInput={event=>onPosition({query:event.currentTarget.value,page:0})}/></label><small>{filtered.length} 个</small></div>}
|
||||
{status==='loading'&&<p className="media-manager-note" role="status">正在读取目录与索引数量…</p>}
|
||||
{status==='loading'&&<FloatingNotice message="正在读取目录与索引数量…" busy />}
|
||||
{status==='error'&&<div className="media-error" role="alert"><p>媒体库摘要读取失败,仍可管理媒体库。</p><button onClick={()=>setRetry(value=>value+1)}>重试读取摘要</button></div>}
|
||||
<div className="media-library-list">{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return <div className="media-library-row" key={library.id}><Folder size={22} strokeWidth={1.5} aria-hidden="true"/><span><strong>{library.name} <em className="media-library-kind" title={library.access==='all'?'所有用户可见':'指定用户可见'}>{labels[library.kind]}</em></strong>{summary?<><small>{count(library,summary)}</small><small className="media-library-directory" title={summary.root}>{library.storage==='openlist'?'OpenList · ':''}{summary.root}</small></>:status==='ready'?<small>目录与数量暂不可用</small>:null}</span><details className="media-library-actions"><summary aria-label={'媒体库操作 '+library.name}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={library.name+'的操作'}><button disabled={busy} onClick={()=>onEdit(library)}>编辑媒体库</button><button onClick={()=>onPermissions(library)}>权限</button></nav></details><footer className="media-library-footer"><span>{summary?summary.missingFiles>0?summary.missingFiles+' 个文件缺失':'无缺失文件记录':'摘要暂不可用'}</span><button disabled={busy} onClick={()=>onScan(library.id)}>重新扫描</button></footer></div>;})}</div>
|
||||
{!filtered.length&&!!libraries.length&&<p className="media-manager-note">没有符合查找条件的媒体库。<button onClick={()=>onPosition({query:'',page:0})}>清除查找</button></p>}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function MediaLoading({label='正在读取媒体库…',layout='grid',square=false,count}:{label?:string;layout?:'grid'|'list'|'detail'|'episodes'|'tracks';square?:boolean;count?:number}){
|
||||
if(layout==='detail')return <section className="media-read-loading media-read-loading-detail" aria-label={label} aria-busy="true"><p role="status">{label}</p><div className="media-detail-loading-hero" aria-hidden="true"><span className="media-detail-loading-cover"/><div className="media-detail-loading-copy"><i className="media-detail-loading-kind"/><i className="media-detail-loading-title"/><i className="media-detail-loading-meta"/><div className="media-detail-loading-actions"><i/><i/></div></div></div><div className="media-detail-loading-description" aria-hidden="true"><i/><i/><i/></div></section>;
|
||||
return <section className={'media-read-loading media-read-loading-'+layout} aria-label={label} aria-busy="true">
|
||||
<p role="status">{label}</p>
|
||||
const notice=<FloatingNotice message={label} busy />;
|
||||
if(layout==='detail')return <>{notice}<section className="media-read-loading media-read-loading-detail" aria-label={label} aria-busy="true"><div className="media-detail-loading-hero" aria-hidden="true"><span className="media-detail-loading-cover"/><div className="media-detail-loading-copy"><i className="media-detail-loading-kind"/><i className="media-detail-loading-title"/><i className="media-detail-loading-meta"/><div className="media-detail-loading-actions"><i/><i/></div></div></div><div className="media-detail-loading-description" aria-hidden="true"><i/><i/><i/></div></section></>;
|
||||
return <>{notice}<section className={'media-read-loading media-read-loading-'+layout} aria-label={label} aria-busy="true">
|
||||
<div className={layout==='grid'?'media-grid':'media-skeleton-list'} aria-hidden="true">{Array.from({length:count??(layout==='grid'?9:layout==='episodes'?12:6)},(_,index)=><div key={index} className="media-skeleton-entry"><span className={'media-skeleton-cover'+(square?' square':'')}/><span className="media-skeleton-copy"><i/><i/></span></div>)}</div>
|
||||
</section>;
|
||||
</section></>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { MediaApi } from '../api/media-api.ts';
|
||||
import type { MediaPlayer } from '../services/player.ts';
|
||||
import {LocateFixed,Music2} from 'lucide-react';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
interface LyricsData {synced:boolean;lines:Array<{time:number|null;text:string}>;source:string}
|
||||
export function Lyrics({api,player}:{api:MediaApi;player:MediaPlayer}){
|
||||
const partId=player.currentPartId;
|
||||
@@ -32,7 +33,7 @@ export function Lyrics({api,player}:{api:MediaApi;player:MediaPlayer}){
|
||||
return ()=>{resize.disconnect();document.fonts?.removeEventListener('loadingdone',align);};
|
||||
},[activeTime,follow]);
|
||||
return <section className="media-lyrics" aria-label="歌词">
|
||||
{error?<div className="media-lyrics-empty" role="alert"><Music2 size={30} aria-hidden="true"/><h3>歌词暂时无法读取</h3><p>{error}</p><button onClick={()=>setRetry(retry+1)}>重试歌词</button></div>:!data?<div className="media-lyrics-empty media-lyrics-loading" role="status"><span>正在读取歌词…</span><i aria-hidden="true"/><i aria-hidden="true"/><i aria-hidden="true"/></div>:!data.lines.length?<div className="media-lyrics-empty"><Music2 size={30} aria-hidden="true"/><h3>暂无歌词</h3><p>可在音频旁放置同名 .lrc 文件。</p></div>:<>
|
||||
{error?<div className="media-lyrics-empty" role="alert"><Music2 size={30} aria-hidden="true"/><h3>歌词暂时无法读取</h3><p>{error}</p><button onClick={()=>setRetry(retry+1)}>重试歌词</button></div>:!data?<><FloatingNotice message="正在读取歌词…" busy /><div className="media-lyrics-empty media-lyrics-loading" role="status" aria-label="正在读取歌词"><i aria-hidden="true"/><i aria-hidden="true"/><i aria-hidden="true"/></div></>:!data.lines.length?<div className="media-lyrics-empty"><Music2 size={30} aria-hidden="true"/><h3>暂无歌词</h3><p>可在音频旁放置同名 .lrc 文件。</p></div>:<>
|
||||
<div className="media-toolbar media-lyrics-toolbar"><small title={data.source==='sidecar'?'本地歌词文件':'内嵌歌词标签'}>{data.synced?'点击歌词跳转':'纯文本歌词'}</small>{data.synced&&<button aria-label={follow?'暂停跟随':'跟随播放'} title={follow?'暂停跟随':'跟随播放'} aria-pressed={follow} onClick={()=>setFollow(!follow)}><LocateFixed size={18} aria-hidden="true"/></button>}</div>
|
||||
<div ref={viewport} className="media-lyrics-lines" tabIndex={0} aria-label="歌词文本" onWheel={()=>setFollow(false)} onTouchStart={()=>setFollow(false)} onKeyDown={event=>{
|
||||
if(event.altKey||event.ctrlKey||event.metaKey||!['ArrowUp','ArrowDown','PageUp','PageDown','Home','End'].includes(event.key))return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {ApiError} from '../../../api/errors.ts';
|
||||
import {Modal} from '../../../ui/modal.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
|
||||
interface Provider { id: string; label: string; kinds: string[]; configured: boolean }
|
||||
interface Candidate { candidateId: string; provider: string; externalId: string; title: string; year?: number; artist?: string; description?: string; evidence?:{level:'strong'|'review'|'conflict';reasons:string[]} }
|
||||
@@ -114,7 +115,7 @@ export function MetadataMatcher({ api, item, onUpdated,layout='inline',onBusyCha
|
||||
{!loading&&!loadError&&!provider&&<div className="media-match-unavailable" role="status"><Search size={26} strokeWidth={1.4} aria-hidden="true"/><strong>{providers.length?'尚未配置可用来源':'当前作品暂无支持的在线来源。'}</strong><p>{providers.length?'请由服务器管理员设置访问凭证或联系信息,再重新读取。':'可以返回手动编辑,本地资料和播放不受影响。'}</p>{providers.length>0&&<button type="button" disabled={busy} onClick={()=>setLoadRetry(value=>value+1)}>重新检查来源</button>}</div>}
|
||||
{loading&&<MediaLoading layout="list" label="正在读取来源与候选…" count={2}/>}
|
||||
{error && <p className="media-error" role="alert">{error}</p>}
|
||||
{busy && <p role="status">正在处理元数据…</p>}
|
||||
{busy && <FloatingNotice message="正在处理元数据…" busy />}
|
||||
{searched && candidates.length === 0 && <p>未找到候选,请修改关键词后重试。</p>}
|
||||
{layout==='page'&&<h2>候选作品</h2>}
|
||||
{candidates.map(candidate => <div className="media-row media-match-candidate" key={candidate.candidateId}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MediaCover } from './cover.tsx';
|
||||
import { EditionDetails } from './edition-details.tsx';
|
||||
import {PersonPortrait} from './person-portrait.tsx';
|
||||
import {ChevronLeft,Play,ArrowUpRight} from 'lucide-react';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export interface NarratorLocation {libraryId:string;name:string|null;search:string;offset:number;workId:string;editionId:string}
|
||||
|
||||
@@ -37,13 +38,13 @@ export function Narrators({api,libraryId,showLibraryName=true,onPlay,onQueue,onD
|
||||
const edition=selected?.editions.find(entry=>entry.id===editionId)||selected?.editions[0];
|
||||
const playable=edition?.parts.filter(part=>part.available)||[];
|
||||
return <section className="media-narrators" data-view={selected?'work':name===null?'directory':'person'} aria-label="演播者浏览">
|
||||
{name!==null?<header className="media-narrator-heading media-page-heading"><button className="media-back-button" aria-label={selected?'← 返回作品':'← 演播者'} title={selected?'返回作品':'返回演播者'} onClick={()=>{if(onBack){onBack();return;}if(selectedId)setSelectedId('');else{setName(null);setOffset(0);}}}><ChevronLeft size={20} aria-hidden="true"/><span className="media-visually-hidden">{selected?'← 返回作品':'← 演播者'}</span></button><h2>{selected?'演播版本':'演播者详情'}</h2></header>:<div className="media-toolbar media-person-toolbar">{libraryControl}<span>{busy?'正在读取演播者…':`${total} 位演播者`}</span></div>}
|
||||
{name!==null?<header className="media-narrator-heading media-page-heading"><button className="media-back-button" aria-label={selected?'← 返回作品':'← 演播者'} title={selected?'返回作品':'返回演播者'} onClick={()=>{if(onBack){onBack();return;}if(selectedId)setSelectedId('');else{setName(null);setOffset(0);}}}><ChevronLeft size={20} aria-hidden="true"/><span className="media-visually-hidden">{selected?'← 返回作品':'← 演播者'}</span></button><h2>{selected?'演播版本':'演播者详情'}</h2></header>:<div className="media-toolbar media-person-toolbar">{libraryControl}<span>{!busy&&`${total} 位演播者`}</span></div>}
|
||||
|
||||
{error&&<p role="alert">{error}<button onClick={()=>setRetry(retry+1)}>重新加载</button></p>}
|
||||
{name!==null&&!selected&&<div className="media-hero media-person-hero"><PersonPortrait name={name}/><div className="media-detail-heading"><small className="media-detail-kind">演播者</small><h1>{name}</h1>{!busy&&!error&&<span className="media-person-count">{total} 部作品</span>}</div></div>}
|
||||
{busy?<p role="status">正在加载…</p>:selected?<article className="media-detail-page"><div className="media-hero"><MediaCover api={api} item={selected} square={false}/><div className="media-detail-heading"><small className="media-detail-kind">有声书</small><h1>{selected.title}</h1><p className="media-detail-credit">{name}参与的版本</p></div></div><div className="media-detail-actions"><button className="media-primary" disabled={acting||!playable.length} onClick={()=>void action(()=>onPlay(playable,0,selected.title))}><Play size={17} aria-hidden="true"/>播放此版本</button><button onClick={()=>onDetail(selected.id)}>完整作品详情<ArrowUpRight size={16} aria-hidden="true"/></button>{selected.editions.length>1&&<label className="media-edition-picker">版本<MediaSelect aria-label="演播版本" value={edition?.id} disabled={acting} onChange={event=>setEditionId(event.currentTarget.value)}>{selected.editions.map(entry=><option key={entry.id} value={entry.id}>{entry.label}</option>)}</MediaSelect></label>}</div>{edition&&<EditionDetails key={edition.id} api={api} edition={edition} layout="detail" busy={acting} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,selected.title))} onQueue={ids=>void action(()=>onQueue(ids))}/>}</article>:<>
|
||||
{busy&&<FloatingNotice message="正在加载演播者…" busy />}{!busy&&selected?<article className="media-detail-page"><div className="media-hero"><MediaCover api={api} item={selected} square={false}/><div className="media-detail-heading"><small className="media-detail-kind">有声书</small><h1>{selected.title}</h1><p className="media-detail-credit">{name}参与的版本</p></div></div><div className="media-detail-actions"><button className="media-primary" disabled={acting||!playable.length} onClick={()=>void action(()=>onPlay(playable,0,selected.title))}><Play size={17} aria-hidden="true"/>播放此版本</button><button onClick={()=>onDetail(selected.id)}>完整作品详情<ArrowUpRight size={16} aria-hidden="true"/></button>{selected.editions.length>1&&<label className="media-edition-picker">版本<MediaSelect aria-label="演播版本" value={edition?.id} disabled={acting} onChange={event=>setEditionId(event.currentTarget.value)}>{selected.editions.map(entry=><option key={entry.id} value={entry.id}>{entry.label}</option>)}</MediaSelect></label>}</div>{edition&&<EditionDetails key={edition.id} api={api} edition={edition} layout="detail" busy={acting} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,selected.title))} onQueue={ids=>void action(()=>onQueue(ids))}/>}</article>:<>
|
||||
{name===null?<div className="media-grid media-people-grid">{narrators.map(narrator=><button className="media-person-tile" key={narrator.name} onClick={()=>{setName(narrator.name);setOffset(0);}}><PersonPortrait name={narrator.name}/><strong title={narrator.name}>{narrator.name}</strong><small>{narrator.works} 部作品 · {narrator.editions} 个版本</small></button>)}</div>:<><h2 className="media-person-works-heading">全部作品</h2><div className="media-grid">{works.map(work=><button className="media-tile" key={work.id} onClick={()=>{setSelectedId(work.id);setEditionId('');}}><MediaCover api={api} item={work} square={false}/><strong>{work.title}</strong>{showLibraryName&&!libraryId&&<small>{work.libraryName}</small>}<small>{work.editions.length} 个相关版本</small></button>)}</div></>}
|
||||
{!total&&!error&&<p className="media-empty">{name===null?'未找到演播者。已有资料请重新扫描媒体库,并检查文件标签或侧车中的演播者信息。':'此演播者暂无关联作品。'}</p>}
|
||||
{!busy&&!total&&!error&&<p className="media-empty">{name===null?'未找到演播者。已有资料请重新扫描媒体库,并检查文件标签或侧车中的演播者信息。':'此演播者暂无关联作品。'}</p>}
|
||||
{total>60&&<nav className="media-toolbar" aria-label="演播者分页"><button disabled={offset===0} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(total/60)}</span><button disabled={offset+60>=total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}
|
||||
</>}
|
||||
</section>;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {ChapterPosition} from './edition-details.tsx';
|
||||
import {SavePlaylist} from './save-playlist.tsx';
|
||||
import {Lyrics} from './lyrics.tsx';
|
||||
import {AudioToolSheet,PlaybackFavorite} from './audio-tools.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {PlaybackProblem} from './playback-problem.tsx';
|
||||
@@ -21,7 +22,7 @@ function PlayingArtwork({api,item,failed,onRetry,player,lyrics,onMore}:{api:Medi
|
||||
const credit=book?[field('narrator')&&String(field('narrator'))+' 演播',field('author')&&String(field('author'))+' 著'].filter(Boolean).join(' · '):[field('artist'),field('album')].filter(Boolean).join(' · ');
|
||||
const title=book?chapter?.title||item.title:item?.kind==='track'?item.title:player.title;
|
||||
return <>{lyrics?<><div className="media-lyrics-heading"><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}</div><Lyrics key={player.currentPartId} api={api} player={player}/></>:<div className="media-playing-artwork">
|
||||
{item?<MediaCover api={api} item={item} square={!book} retryable/>:<div className="media-artwork-placeholder" role="status">{failed?'作品资料暂不可用':'正在读取作品资料…'}</div>}
|
||||
{!item&&!failed&&<FloatingNotice message="正在读取作品资料…" busy />}{item?<MediaCover api={api} item={item} square={!book} retryable/>:<div className="media-artwork-placeholder" role="status" aria-label={failed?'作品资料暂不可用':'正在读取作品资料'}>{failed?'作品资料暂不可用':null}</div>}
|
||||
{book&&chapter&&<p className="media-playing-chapter">第 {edition!.parts.indexOf(chapter)+1} / {edition!.parts.length} 章 · {item.title}</p>}
|
||||
<div className="media-playing-copy"><div><h2 className="media-playing-title">{title}</h2>{credit&&<p>{credit}</p>}{failed&&<p role="alert">作品资料读取失败。<button onClick={onRetry}>重新读取作品资料</button></p>}</div><button className="media-icon-button" aria-label="当前内容操作" onClick={onMore}><Ellipsis size={20} aria-hidden="true"/></button></div>
|
||||
</div>}</>;
|
||||
@@ -89,7 +90,7 @@ export function PlaybackControls({player,api,panel,onPanelChange,onBack,onFavori
|
||||
<section className="media-playback-status" data-panel={view} data-channel={channel??'unknown'} aria-label="播放控制">
|
||||
{(player.isVideo||!itemId)&&<header className="media-now-playing"><div><small>正在播放</small><strong>{player.title}</strong></div><button className="media-symbol-button" aria-label="结束" title="结束播放" onClick={()=>void player.stop()}><Square size={18} aria-hidden="true"/></button></header>}
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})} backLabel={view==='main'?'返回浏览':'返回播放'}/>}
|
||||
{!player.error&&player.loadingStatus&&<p role="status" className="media-loading-status">{player.loadingStatus}</p>}
|
||||
{!player.error&&player.loadingStatus&&<FloatingNotice message={player.loadingStatus} busy />}
|
||||
{player.canOpenNativeVideo&&<button onClick={()=>void player.openNativeVideo()}>打开 Android 视频播放器</button>}
|
||||
{view==='chapters'?<PlayingChapters key={itemId} player={player} api={api} item={item} error={failed} cause={cause} onRetry={()=>setRetry(value=>value+1)}/>:view==='queue'?<CurrentPlaylist player={player}/>:!player.isVideo&&itemId&&<PlayingArtwork api={api} item={item} failed={!!failed} onRetry={()=>setRetry(value=>value+1)} player={player} lyrics={lyrics} onMore={()=>open('more')}/>}
|
||||
{view!=='queue'&&view!=='chapters'&&<div className="media-audio-bottom">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -32,7 +33,7 @@ function JobResults({job,api,navigate}:{job:Job;api:MediaApi;navigate:(channel:M
|
||||
const rows=job.counts?(result?.items??[]):legacyRows.slice(current*50,current*50+50);
|
||||
return <section className="media-job-results" aria-label="任务结果">
|
||||
<div className="media-toolbar media-job-result-filters"><label>结果状态 <MediaSelect aria-label="结果状态" value={filter} onChange={event=>{setFilter(event.currentTarget.value);setPage(0);}}><option value="all">全部 · {jobTotal(job)}</option>{states.map(state=><option key={state} value={state}>{names[state]} · {countState(job,state)}</option>)}</MediaSelect></label><span>{total} 项</span></div>
|
||||
{job.counts&&status==='loading'&&<p role="status">正在读取任务结果…</p>}
|
||||
{job.counts&&status==='loading'&&<FloatingNotice message="正在读取任务结果…" busy />}
|
||||
{job.counts&&status==='error'&&<p role="alert">任务结果读取失败。<button onClick={()=>setRetry(value=>value+1)}>重新读取结果</button></p>}
|
||||
{ready&&!rows.length&&<p role="status">当前没有此状态的结果。</p>}
|
||||
{ready&&rows.map(row=><div className="media-row media-task-result" key={row.itemId}><span>{row.title||'作品已移除'}<small>{names[row.state]||row.state}{row.error?' · '+(row.error==='server-restarted'?'服务重启,处理已中断':row.error):''}</small></span>{row.channel&&row.title&&<button className="media-task-icon" aria-label="查看作品" title="查看作品" onClick={()=>navigate(row.channel!,row.itemId)}><ChevronRight size={18} aria-hidden="true"/></button>}</div>)}
|
||||
@@ -85,7 +86,7 @@ export function ScrapeJobs({api,libraries,navigate}:{api:MediaApi;libraries:Libr
|
||||
const visibleItems=query.trim()?items.filter(item=>item.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())):items;
|
||||
const taskHistory=<section ref={history} tabIndex={-1} className="media-task-history" aria-label="最近刮削任务">
|
||||
<h2 className="media-task-section-title">最近任务</h2>
|
||||
{jobsState==='loading'&&<p role="status">正在读取任务状态…</p>}
|
||||
{jobsState==='loading'&&<FloatingNotice message="正在读取任务状态…" busy />}
|
||||
{jobsState==='error'&&<p role="alert">任务状态读取失败。{jobs.length?'下方为上次读取的结果,可能已变化。':''}请重新读取后操作。<button disabled={busy} onClick={()=>setJobsRetry(value=>value+1)}>重新读取任务</button></p>}
|
||||
{jobsState==='ready'&&!jobs.length&&<p>暂无批量刮削任务。</p>}
|
||||
{jobs.map(job=>{const total=jobTotal(job),ended=Math.max(0,total-countState(job,'running')-countState(job,'pending')),review=countState(job,'review'),failed=countState(job,'failed');return <details className="media-scrape-job" key={job.id} onToggle={event=>{const opened=event.currentTarget.open;setOpenJobs(ids=>opened?[...new Set([...ids,job.id])]:ids.filter(id=>id!==job.id));}}><summary aria-label={job.provider+' · '+(names[job.state]||job.state)+' · 已结束 '+ended+'/'+total+' 项'}><span className="media-task-copy"><strong>{providers.find(p=>p.id===job.provider)?.label||job.provider} · 元数据匹配</strong><small>已结束 {ended}/{total} 项{review?' · 待确认 '+review:''}{failed?' · 失败 '+failed:''}</small></span><span className="media-task-state" data-state={job.state==='running'?'running':failed?'failed':review?'review':job.state}>{job.state==='running'?'进行中':failed?'有失败项':review?'待确认':names[job.state]||job.state}</span><ChevronDown className="media-task-chevron" size={16} aria-hidden="true"/>{job.state==='running'&&<progress className="media-task-progress" aria-label="匹配进度" max={total||1} value={ended}/>}</summary>
|
||||
@@ -107,7 +108,7 @@ export function ScrapeJobs({api,libraries,navigate}:{api:MediaApi;libraries:Libr
|
||||
<label>匹配方式<MediaSelect aria-label="匹配方式" value={matchMode} disabled={busy} onChange={e=>setMatchMode(e.currentTarget.value as 'strong'|'first'|'manual')}><option value="strong">自动匹配唯一强候选</option><option value="first">默认采用第一条候选</option><option value="manual">手动确认每项匹配</option></MediaSelect></label></div>
|
||||
{!provider&&<p role="status">当前媒体库没有已配置的匹配来源。请管理员配置支持此类内容的来源后重试。<button disabled={busy} onClick={()=>{setError('');setProviderRetry(value=>value+1);}}>重新检查来源</button></p>}
|
||||
<div className="media-scrape-selection-toolbar"><label className="media-scrape-search">筛选作品<input value={query} placeholder="输入标题过滤本页" onInput={e=>setQuery(e.currentTarget.value)}/></label><div><button disabled={busy||loading||!visibleItems.some(item=>!selected.includes(item.id))||selected.length>=500} onClick={()=>setSelected(current=>[...new Set([...current,...visibleItems.map(item=>item.id)])].slice(0,500))}>选择本页</button><button disabled={busy||loading||selectingAll} onClick={()=>void selectAllResults()}>{selectingAll?'正在选择…':'选择全部结果'}</button><button disabled={busy||loading||!visibleItems.some(item=>selected.includes(item.id))} onClick={()=>setSelected(current=>current.filter(id=>!visibleItems.some(item=>item.id===id)))}>取消本页</button><button disabled={busy||!selected.length} onClick={()=>setSelected([])}>清除选择</button></div></div>
|
||||
{loading?<p role="status">正在加载作品…</p>:<div className="media-scrape-item-list">{visibleItems.map(item=><label className="media-user-option" key={item.id}><input type="checkbox" disabled={busy||(!selected.includes(item.id)&&selected.length>=500)} checked={selected.includes(item.id)} onChange={e=>setSelected(current=>e.currentTarget.checked?[...current,item.id]:current.filter(id=>id!==item.id))}/><span>{item.title}</span></label>)}{!visibleItems.length&&<p role="status">本页没有匹配的作品。</p>}</div>}
|
||||
{loading?<FloatingNotice message="正在加载作品…" busy />:<div className="media-scrape-item-list">{visibleItems.map(item=><label className="media-user-option" key={item.id}><input type="checkbox" disabled={busy||(!selected.includes(item.id)&&selected.length>=500)} checked={selected.includes(item.id)} onChange={e=>setSelected(current=>e.currentTarget.checked?[...current,item.id]:current.filter(id=>id!==item.id))}/><span>{item.title}</span></label>)}{!visibleItems.length&&<p role="status">本页没有匹配的作品。</p>}</div>}
|
||||
<div className="media-scrape-selection-footer"><button disabled={loading||offset===0} onClick={()=>setOffset(value=>Math.max(0,value-60))}>上一页</button><span>{total} 项 · 已选 {selected.length}</span><button disabled={loading||offset+60>=total} onClick={()=>setOffset(value=>value+60)}>下一页</button></div>
|
||||
<button disabled={busy||loading||jobsState!=='ready'||running||!selected.length||!allowed.includes(kind)} onClick={()=>void action('scrape-jobs',{provider,itemIds:selected,...(matchMode!=='strong'?{matchMode}: {})})}>开始批量匹配</button>
|
||||
</details>
|
||||
|
||||
@@ -5,13 +5,13 @@ import {MediaThemeSettings} from './theme-settings.tsx';
|
||||
import {readMediaTheme,mediaThemes,type MediaThemeId} from '../services/theme.ts';
|
||||
export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account';
|
||||
import { useState } from 'react';
|
||||
import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Blocks,Palette} from 'lucide-react';
|
||||
import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Palette} from 'lucide-react';
|
||||
import type {MediaPlayer} from '../services/player.ts';
|
||||
import type {MediaApi,MediaAccount} from '../api/media-api.ts';
|
||||
import {MediaProviderSettings} from './provider-settings.tsx';
|
||||
import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts';
|
||||
|
||||
export function MediaSettings({scope,preferences,player,admin,api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,theme,onThemeChange}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;theme?:MediaThemeId;onThemeChange?:(theme:MediaThemeId)=>void}){
|
||||
export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,theme,onThemeChange}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;theme?:MediaThemeId;onThemeChange?:(theme:MediaThemeId)=>void}){
|
||||
const [localTheme,setLocalTheme]=useState(()=>readMediaTheme(scope));
|
||||
const selectedTheme=theme??localTheme;
|
||||
const [localPanel,setLocalPanel]=useState<SettingsPanel>('home');
|
||||
@@ -39,7 +39,7 @@ export function MediaSettings({scope,preferences,player,admin,api,account,onSave
|
||||
{link(SlidersHorizontal,'浏览偏好',preferences.density==='compact'?'紧凑':'宽松',openBrowse)}
|
||||
{link(Headphones,'播放设置','倍速与连续播放',()=>setPanel('playback'))}
|
||||
</section>
|
||||
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}{api&&link(Blocks,'来源与刮削','在线来源与本地资料',()=>setPanel('plugins'))}</section>}
|
||||
{admin&&<section className="media-settings-group"><h2>管理员</h2>{link(FolderOpen,'媒体库管理','全部影音媒体库',onManage)}{onTasks&&link(RefreshCw,'扫描与刮削','任务与匹配结果',onTasks)}</section>}
|
||||
{account&&<section className="media-settings-group"><h2>账号</h2>{link(UserRound,'账号与连接','',()=>setPanel('account'))}</section>}
|
||||
</>:panel==='theme'?<MediaThemeSettings scope={scope} selected={selectedTheme} onChange={value=>{setLocalTheme(value);onThemeChange?.(value);}}/>:panel==='browse'?<form className="media-form media-settings-panel" onSubmit={event=>{event.preventDefault();save();}}>
|
||||
<p>仅影响影音页面,在当前设备为此服务器和账号保存。</p>
|
||||
@@ -49,6 +49,6 @@ export function MediaSettings({scope,preferences,player,admin,api,account,onSave
|
||||
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" checked={draft.showLibraryName} onChange={event=>change({showLibraryName:event.currentTarget.checked})}/></label>
|
||||
<div className="media-settings-footer"><button type="button" onClick={()=>{setDraft({...defaultMediaPreferences});setSaved(false);}}>恢复默认值</button><button className="media-primary" type="submit">保存偏好</button></div>
|
||||
{saved&&<p role="status">影音偏好已保存</p>}{error&&<p role="alert">{error}</p>}
|
||||
</form>:panel==='plugins'?admin&&api?<MediaProviderSettings api={api}/>:<section className="media-settings-panel" role="alert"><h2>需要管理员权限</h2><p>请返回影音设置查看可用功能。</p></section>:panel==='account'&&account?<section className="media-account-panel"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><h2>{account.displayName||account.username}</h2><p>@{account.username} · {account.role==='admin'?'管理员':'普通用户'}</p><p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离。</p><dl><dt>当前服务器</dt><dd>{account.server||window.location.origin}</dd></dl></section>:<PlaybackSettings scope={scope} player={player} {...(onPlayback?{onPlayback}:{})}/>}
|
||||
</form>:panel==='plugins'?admin&&_api?<MediaProviderSettings api={_api}/>:<section className="media-settings-panel" role="alert"><h2>需要管理员权限</h2><p>请返回影音设置查看可用功能。</p></section>:panel==='account'&&account?<section className="media-account-panel"><div className="media-account-avatar" aria-hidden="true">{Array.from(account.displayName||account.username)[0]}</div><h2>{account.displayName||account.username}</h2><p>@{account.username} · {account.role==='admin'?'管理员':'普通用户'}</p><p className="media-manager-note">共享作品资料,收藏、队列和播放进度按账号隔离。</p><dl><dt>当前服务器</dt><dd>{account.server||window.location.origin}</dd></dl></section>:<PlaybackSettings scope={scope} player={player} {...(onPlayback?{onPlayback}:{})}/>}
|
||||
</section>;
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import type {Detail,MediaApi} from '../api/media-api.ts';
|
||||
import {MediaLoading} from './loading.tsx';
|
||||
import {MediaScreenError} from './screen-error.tsx';
|
||||
import {PlaybackProblem} from './playback-problem.tsx';
|
||||
import {FloatingNotice} from '../../../ui/floating-notice.tsx';
|
||||
|
||||
type Tool='episodes'|'subtitles'|'tracks'|'volume'|'more'|'versions';
|
||||
const titles:Record<Tool,string>={episodes:'选集',subtitles:'字幕',tracks:'音轨',volume:'音量',more:'更多播放选项',versions:'播放版本'};
|
||||
@@ -47,7 +46,6 @@ export function VideoControls({player,api,onBack}:{player:MediaPlayer;api:MediaA
|
||||
<div className="media-video-caption"><div><strong>{item?.title||player.title}</strong>{edition&&<small>{edition.label}</small>}</div></div>
|
||||
<div className="media-video-toolbar media-video-tools">{entries.length>1&&<button onClick={()=>open('episodes')}><Layers size={20} aria-hidden="true"/>选集</button>}<button onClick={()=>open('subtitles')}><Subtitles size={20} aria-hidden="true"/>字幕</button><button onClick={()=>open('tracks')}><AudioLines size={20} aria-hidden="true"/>音轨</button><button onClick={()=>open('versions')}><Layers size={20} aria-hidden="true"/>版本</button><button aria-label="更多播放选项" onClick={()=>open('more')}><Ellipsis size={20} aria-hidden="true"/>更多</button></div>
|
||||
{player.error&&<PlaybackProblem player={player} {...(item?.editions.length?{onVersions:()=>open('versions')}:{})} {...(onBack?{onBack}:{})}/>}
|
||||
{!player.error&&player.loadingStatus&&<FloatingNotice message={player.loadingStatus} busy />}
|
||||
{notice&&<p className="media-error" role="alert">{notice}</p>}
|
||||
{plot&&<details className="media-video-description" open><summary>剧情简介</summary><p>{plot}</p></details>}
|
||||
{item?.kind==='episode'&&item.parentId&&<VideoEpisodeRail key={item.parentId} api={api} item={item} player={player}/>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Pagination, Select } from '@mantine/core';
|
||||
import { Button } from '@mantine/core';
|
||||
import { FolderOpen, History, LibraryBig, ListVideo, MoreHorizontal, Search, Settings2, SlidersHorizontal, Star } from 'lucide-react';
|
||||
import { Link, NavLink, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { catalogQuery, useMediaLibraries } from '../queries/media.queries.ts';
|
||||
@@ -11,6 +12,11 @@ import { ContinuePlaying } from '../components/continue-playing.tsx';
|
||||
import { usePlaybackStore } from '../stores/playback.store.ts';
|
||||
import { EmptyMediaLibrary } from '../components/empty-library.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { MediaSelect } from '../components/select.tsx';
|
||||
|
||||
const countLabels: Record<string, string> = {
|
||||
video: '项', movie: '部电影', series: '部剧集', album: '张专辑', artist: '位歌手', track: '首曲目', audiobook: '部有声书', narrator: '位演播者',
|
||||
};
|
||||
|
||||
export function MediaCatalogPage() {
|
||||
const runtime = useRuntime(), navigate = useNavigate(), channel = useMediaChannel(), { category } = useParams(), [params, setParams] = useSearchParams();
|
||||
@@ -21,13 +27,21 @@ export function MediaCatalogPage() {
|
||||
const offset = Number(params.get('offset')) || 0;
|
||||
const patch = (values: Record<string, string>) => { const next = new URLSearchParams(params); for (const [key, value] of Object.entries(values)) value ? next.set(key, value) : next.delete(key); setParams(next); };
|
||||
const channelLibraries = libraries.data?.items.filter(item => item.kind === channel) ?? [];
|
||||
const selectedLibrary = params.get('library') ?? '';
|
||||
const selectedFromUrl = params.get('library');
|
||||
const selectedLibrary = selectedFromUrl ?? (channelLibraries.length === 1 ? channelLibraries[0]!.id : '');
|
||||
const emptyCatalog = !query.isPending && !query.error && query.data?.total === 0;
|
||||
// An empty channel without a configured library uses the focused legacy empty state;
|
||||
// there is no source selector or category toolbar to act on in that state.
|
||||
const hasLibraries = channelLibraries.length > 0;
|
||||
const emptyWithoutLibraries = emptyCatalog && !hasLibraries;
|
||||
const libraryOptions = [{ value: '', label: '全部媒体库' }, ...(channelLibraries.map(item => ({ value: item.id, label: item.name })) )];
|
||||
useEffect(() => {
|
||||
if (selectedFromUrl === null && channelLibraries.length === 1) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('library', channelLibraries[0]!.id);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
}, [channelLibraries, params, selectedFromUrl, setParams]);
|
||||
return <MediaPageFrame className="media-catalog-page" title={channelLabels[channel]} actions={<>
|
||||
<Button component={Link} to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Button>
|
||||
<details className="media-actions media-catalog-actions">
|
||||
@@ -58,17 +72,27 @@ export function MediaCatalogPage() {
|
||||
/>}
|
||||
{!emptyWithoutLibraries && <div className="media-toolbar media-browse-tools">
|
||||
<div className="media-library-filter">
|
||||
<Select aria-label="来源库" data={libraryOptions} value={selectedLibrary} onChange={value => patch({ library: value ?? '', offset: '' })} />
|
||||
{query.data && <span className="media-catalog-count">{query.data.total}{channel === 'music' && current.kind === 'album' ? ' 张专辑' : ' 项'}</span>}
|
||||
<MediaSelect variant="plain" aria-label="来源库" value={selectedLibrary} onChange={event => patch({ library: event.currentTarget.value, offset: '' })}>
|
||||
{libraryOptions.map(option => <option value={option.value} key={option.value}>{option.label}</option>)}
|
||||
</MediaSelect>
|
||||
{query.data && <span className="media-catalog-count">{query.data.total} {countLabels[current.kind] ?? '项'}</span>}
|
||||
</div>
|
||||
<div className="media-browse-actions">
|
||||
<Button component={Link} to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} className="media-icon-button" aria-label="打开文件夹" title="打开文件夹"><FolderOpen size={19} aria-hidden="true" /></Button>
|
||||
<label className="media-sort-control" aria-label="排序">
|
||||
<SlidersHorizontal size={19} aria-hidden="true" />
|
||||
<Select aria-label="排序" data={[{ value: 'default', label: '默认顺序' }, { value: 'title-asc', label: '名称升序' }, { value: 'title-desc', label: '名称降序' }]} value={params.get('sort') ?? 'default'} onChange={value => patch({ sort: value ?? 'default', offset: '' })} />
|
||||
</label>
|
||||
<details className="media-browse-filters">
|
||||
<summary aria-label="筛选与排序" title="筛选与排序"><SlidersHorizontal size={19} aria-hidden="true" />{params.get('sort') && params.get('sort') !== 'default' && <i aria-label="已应用筛选" />}</summary>
|
||||
<div className="media-browse-filter-panel">
|
||||
<label>排序<MediaSelect aria-label="排序" value={params.get('sort') ?? 'default'} onChange={event => patch({ sort: event.currentTarget.value, offset: '' })}>
|
||||
<option value="default">默认顺序</option><option value="title-asc">名称升序</option><option value="title-desc">名称降序</option>
|
||||
</MediaSelect></label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>}
|
||||
{!emptyCatalog && query.data && <><MediaItemGrid items={query.data.items} channel={channel} /><div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(offset / 60) + 1} onChange={page => patch({ offset: String((page - 1) * 60) })} /></div></>}
|
||||
{!emptyCatalog && query.data && <><MediaItemGrid items={query.data.items} channel={channel} />{query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
|
||||
<Button disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= 60 ? String(offset - 60) : '' })}>上一页</Button>
|
||||
<span>{Math.floor(offset / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
|
||||
<Button disabled={offset + 60 >= query.data.total || query.isFetching} onClick={() => patch({ offset: String(offset + 60) })}>下一页</Button>
|
||||
</nav>}</>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Card, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { Alert, Button } from '@mantine/core';
|
||||
import { Ellipsis, Heart, Play } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -13,7 +13,7 @@ import { EditionDetails } from '../components/edition-details.tsx';
|
||||
import { MediaSelect } from '../components/select.tsx';
|
||||
import { MissingEdition } from '../components/missing-edition.tsx';
|
||||
import { AlbumPlayback } from '../components/album-playback.tsx';
|
||||
import { SeriesPlayback, SeriesSeasons, SeasonPlayback } from '../components/season-playback.tsx';
|
||||
import { SeriesSeasons, SeasonPlayback, seasonQueue, type SeasonEpisode } from '../components/season-playback.tsx';
|
||||
import { MediaChildList } from '../components/child-list.tsx';
|
||||
import { ResourcePanel } from '../components/resource-panel.tsx';
|
||||
import { MetadataSources } from '../components/metadata-sources.tsx';
|
||||
@@ -54,17 +54,17 @@ export function MediaDetailPage() {
|
||||
setResourcePanel(panel);
|
||||
};
|
||||
const detailActions = detail ? <details className="media-actions media-item-actions" ref={detailMenu}>
|
||||
<summary aria-label={detail.kind === 'artist' ? '歌手操作' : '作品操作'} title="作品操作"><Ellipsis size={20} aria-hidden="true" /></summary>
|
||||
<summary aria-label={detail.kind === 'artist' ? '歌手操作' : '作品操作'} title={detail.kind === 'artist' ? '歌手操作' : '作品操作'}><Ellipsis size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label={detail.kind === 'artist' ? '歌手操作' : '作品操作'}>
|
||||
<button type="button" disabled={toggleFavorite.isPending} onClick={() => { detailMenu.current?.removeAttribute('open'); toggleFavorite.mutate(!favorite.data?.favorite); }}>{favorite.data?.favorite ? (detail.kind === 'artist' ? '取消收藏歌手' : '取消收藏作品') : (detail.kind === 'artist' ? '收藏歌手' : '收藏作品')}</button>
|
||||
{detail.kind !== 'artist' && <>
|
||||
<button type="button" disabled={toggleFavorite.isPending} onClick={() => { detailMenu.current?.removeAttribute('open'); toggleFavorite.mutate(!favorite.data?.favorite); }}>{favorite.data?.favorite ? '取消收藏作品' : '收藏作品'}</button>
|
||||
{detail.editions.some(edition => edition.parts.length > 0) && <button type="button" onClick={() => openResourcePanel('files')}>资源信息</button>}
|
||||
{(detail.editions.length > 1 || (admin && detail.editions.length > 0)) && <button type="button" onClick={() => openResourcePanel('versions')}>{admin ? '版本管理' : '播放版本'}</button>}
|
||||
<button type="button" onClick={() => openResourcePanel('source')}>资料来源</button>
|
||||
</>}
|
||||
{admin && <>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/metadata`)}>编辑资料</button>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/match`)}>匹配元数据</button>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/metadata`)}>{detail.kind === 'artist' ? '编辑歌手资料' : '编辑资料'}</button>
|
||||
<button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/match`)}>{detail.kind === 'artist' ? '匹配歌手资料' : '匹配元数据'}</button>
|
||||
{detail.kind === 'audiobook' && <button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/chapters`)}>章节列表</button>}
|
||||
{['track', 'album', 'season', 'episode'].includes(detail.kind) && <button type="button" onClick={() => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/structure`)}>结构整理</button>}
|
||||
</>}
|
||||
@@ -107,21 +107,29 @@ function DetailContent({ detail, channel, favorite, favoriteBusy, onToggleFavori
|
||||
const resourceSummary = edition
|
||||
? `${new Set(edition.parts.map(part => part.assetId)).size} 个文件 · ${edition.label}`
|
||||
: detail.children.length + (detail.kind === 'album' ? ' 首曲目' : detail.kind === 'series' ? ' 季 · 资源见单集详情' : ' 项内容');
|
||||
return <Stack className="media-detail-page">
|
||||
<Group className="media-hero" align="start"><MediaCover api={runtime.mediaApi} item={detail} square={['album', 'artist', 'track'].includes(detail.kind)} /><Stack className="media-detail-hero-copy" flex={1}><MediaDetailHeading item={detail} edition={edition} {...(detail.kind === 'album' ? { trackCount: detail.children.length } : {})} {...(detail.kind === 'series' ? { seasonCount: detail.children.length } : {})} /></Stack></Group>
|
||||
<Group className="media-detail-actions"><Button className="media-primary" leftSection={<Play size={16} />} disabled={!playable.length} onClick={() => onPlay(playable)}>{detail.kind === 'movie' ? '播放电影' : detail.kind === 'series' ? '播放剧集' : detail.kind === 'audiobook' ? '播放有声书' : detail.kind === 'album' ? '播放专辑' : '播放'}</Button>{detail.kind !== 'album' && <Button className="media-detail-favorite" variant={favorite ? 'filled' : 'light'} {...(favorite ? { color: 'pink' as const } : {})} loading={favoriteBusy} aria-label={favorite ? '取消收藏' : '收藏'} title={favorite ? '取消收藏' : '收藏'} onClick={onToggleFavorite}><Heart size={18} fill={favorite ? 'currentColor' : 'none'} aria-hidden="true" /></Button>}</Group>
|
||||
{plot && <Card className="media-detail-description" withBorder><Title order={3}>{['movie', 'series', 'season', 'episode'].includes(detail.kind) ? '剧情简介' : detail.kind === 'audiobook' ? '内容简介' : '简介'}</Title><Text>{plot}</Text></Card>}
|
||||
{edition && edition.parts.length > 0 && !playable.length && <MissingEdition title={detail.title} label={edition.label} busy={false} onRefresh={onRefresh} onChooseVersion={detail.editions.length > 1 ? () => onResourcePanelChange('versions') : undefined} />}
|
||||
{detail.kind !== 'artist' && <ResourcePanel api={runtime.mediaApi} assets={assets} summary={resourceSummary} versionPicker={detail.editions.length > 1 && <label className="media-edition-picker">版本<MediaSelect aria-label="播放版本" value={edition?.id ?? ''} onChange={event => { const next = new URLSearchParams(params); next.set('edition', event.currentTarget.value); setParams(next, { replace: true }); }}>{detail.editions.map(value => <option key={value.id} value={value.id}>{value.label}</option>)}</MediaSelect></label>} editionOptions={editionOptions} sourceInfo={<MetadataSources item={detail} />} openPanel={resourcePanel} onPanelChange={onResourcePanelChange} showTrigger={false} />}
|
||||
{detail.kind === 'album' && <AlbumPlayback api={runtime.mediaApi} id={detail.id} credit={credit} onPlay={onPlayEntries} onQueue={onQueue} onDetail={id => navigate(`/media/music/items/${encodeURIComponent(id)}`)} onTracksRead={tracks => setAlbumAssets([...new Map(tracks.flatMap(track => track.editions.flatMap(value => value.parts.map(part => ({ id: part.assetId, title: part.title })))).map(asset => [asset.id, asset])).values()])} />}
|
||||
{detail.kind === 'series' && <>
|
||||
<SeriesPlayback id={detail.id} api={runtime.mediaApi} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />
|
||||
<SeriesSeasons api={runtime.mediaApi} seasons={detail.children} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />
|
||||
</>}
|
||||
{detail.kind === 'season' && <SeasonPlayback api={runtime.mediaApi} id={detail.id} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />}
|
||||
{edition && playable.length > 0 && !['album', 'series'].includes(detail.kind) && !(detail.kind === 'movie' && edition.parts.length === 1) && <EditionDetails item={detail} edition={edition} api={runtime.mediaApi} busy={false} showTools={false} onPlay={onPlay} onQueue={onQueue} onRefresh={onRefresh} onChooseVersion={detail.editions.length > 1 ? () => onResourcePanelChange('versions') : undefined} />}
|
||||
{detail.kind === 'artist' && <ArtistInfo item={detail} />}
|
||||
{detail.kind === 'artist' && <ArtistTracks
|
||||
const albumSidebar = detail.kind === 'album' && plot ? <aside className="media-detail-sidebar"><section className="media-detail-description"><h2>关于这张专辑</h2><p>{plot}</p></section></aside> : undefined;
|
||||
const detailClass = 'media-detail-page' + (detail.kind === 'artist' ? ' media-person-detail' : '');
|
||||
const artistPlot = detail.kind === 'artist' ? plot : '';
|
||||
const playSeries = async () => {
|
||||
const result = await runtime.mediaApi.request<{ episodes: SeasonEpisode[] }>(`items/${encodeURIComponent(detail.id)}/series-playback`);
|
||||
const queue = seasonQueue(result.episodes, 0, {});
|
||||
if (queue.entries.length) await onPlayEntries(queue.entries);
|
||||
else if (result.episodes[0]) navigate(`/media/video/items/${encodeURIComponent(result.episodes[0].id)}`);
|
||||
};
|
||||
const resourceControl = detail.kind !== 'artist' && <ResourcePanel api={runtime.mediaApi} assets={assets} summary={resourceSummary} versionPicker={detail.editions.length > 1 && <label className="media-edition-picker">版本<MediaSelect aria-label="播放版本" value={edition?.id ?? ''} onChange={event => { const next = new URLSearchParams(params); next.set('edition', event.currentTarget.value); setParams(next, { replace: true }); }}>{detail.editions.map(value => <option key={value.id} value={value.id}>{value.label}</option>)}</MediaSelect></label>} editionOptions={editionOptions} sourceInfo={<MetadataSources item={detail} />} openPanel={resourcePanel} onPanelChange={onResourcePanelChange} showTrigger={false} />;
|
||||
if (edition?.parts.length && !playable.length) return <>{resourceControl}<MissingEdition title={detail.title} label={edition.label} busy={false} onRefresh={onRefresh} onChooseVersion={detail.editions.length > 1 ? () => onResourcePanelChange('versions') : undefined} /></>;
|
||||
return <>{resourceControl}<article className={detailClass}>
|
||||
<div className="media-hero"><MediaCover api={runtime.mediaApi} item={detail} square={['album', 'artist', 'track'].includes(detail.kind)} /><div className={detail.kind === 'artist' ? 'media-person-copy' : 'media-detail-hero-copy'}><MediaDetailHeading item={detail} edition={edition} {...(detail.kind === 'album' ? { trackCount: detail.children.length } : {})} {...(detail.kind === 'series' ? { seasonCount: detail.children.length } : {})} />{detail.kind === 'artist' && <span className="media-person-count">{detail.children.filter(item => item.kind === 'album').length} 张专辑</span>}</div></div>
|
||||
<div className="media-detail-actions">{detail.kind === 'series' && <Button className="media-primary" leftSection={<Play size={16} />} disabled={!detail.children.length} onClick={() => void playSeries()}>播放剧集</Button>}{edition && <Button className="media-primary" leftSection={<Play size={16} />} disabled={!playable.length} onClick={() => onPlay(playable)}>{detail.kind === 'movie' ? '播放电影' : detail.kind === 'track' ? '播放音频' : detail.kind === 'audiobook' ? '开始收听' : '播放此版本'}</Button>}{detail.kind !== 'album' && <Button className="media-detail-favorite" variant="subtle" loading={favoriteBusy} aria-label={favorite ? '取消收藏' : '收藏'} title={favorite ? '取消收藏' : '收藏'} onClick={onToggleFavorite}><Heart size={18} fill={favorite ? 'currentColor' : 'none'} aria-hidden="true" /></Button>}</div>
|
||||
{artistPlot && <p className="media-person-bio">{artistPlot}</p>}
|
||||
<div className={'media-detail-columns' + (detail.kind === 'album' ? ' media-album-detail' : '')}><div className="media-detail-main">
|
||||
{!['artist', 'album'].includes(detail.kind) && plot && <section className="media-detail-description"><h2>{['movie', 'series', 'season', 'episode'].includes(detail.kind) ? '剧情简介' : '内容简介'}</h2><p>{plot}</p></section>}
|
||||
{detail.kind === 'album' && <AlbumPlayback api={runtime.mediaApi} id={detail.id} credit={credit} sidebar={albumSidebar} onPlay={onPlayEntries} onQueue={onQueue} onDetail={id => navigate(`/media/music/items/${encodeURIComponent(id)}`)} onTracksRead={tracks => setAlbumAssets([...new Map(tracks.flatMap(track => track.editions.flatMap(value => value.parts.map(part => ({ id: part.assetId, title: part.title })))).map(asset => [asset.id, asset])).values()])} />}
|
||||
{detail.kind === 'series' && <SeriesSeasons api={runtime.mediaApi} seasons={detail.children} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />}
|
||||
{detail.kind === 'season' && <SeasonPlayback api={runtime.mediaApi} id={detail.id} currentPartId={runtime.player.currentPartId} onPlay={onPlayEntries} onDetail={id => navigate(`/media/video/items/${encodeURIComponent(id)}`)} />}
|
||||
{edition && playable.length > 0 && !['album', 'series'].includes(detail.kind) && !(detail.kind === 'movie' && edition.parts.length === 1) && <EditionDetails item={detail} edition={edition} api={runtime.mediaApi} busy={false} showTools={false} onPlay={onPlay} onQueue={onQueue} onRefresh={onRefresh} {...(detail.editions.length > 1 ? { onChooseVersion: () => onResourcePanelChange('versions') } : {})} {...(detail.kind === 'audiobook' ? { onShowAll: () => navigate(`/media/${channel}/items/${encodeURIComponent(detail.id)}/chapters`) } : {})} />}
|
||||
{detail.kind === 'artist' && <ArtistInfo item={detail} />}
|
||||
{detail.kind === 'artist' && <ArtistTracks
|
||||
api={runtime.mediaApi}
|
||||
artist={detail}
|
||||
onPlay={async id => {
|
||||
@@ -131,6 +139,7 @@ function DetailContent({ detail, channel, favorite, favoriteBusy, onToggleFavori
|
||||
}}
|
||||
onDetail={id => navigate(`/media/music/items/${encodeURIComponent(id)}`)}
|
||||
/>}
|
||||
{!['album', 'series', 'season', 'audiobook'].includes(detail.kind) && detail.children.length > 0 && <Card withBorder><Title order={3}>相关内容</Title><MediaChildList items={detail.children} label="内容" renderItems={items => <Stack>{items.map(item => <Button key={item.id} component={Link} variant="subtle" justify="start" to={`/media/${channel}/items/${encodeURIComponent(item.id)}`}>{item.title}</Button>)}</Stack>} /></Card>}
|
||||
</Stack>;
|
||||
{!['album', 'series', 'season'].includes(detail.kind) && detail.children.length > 0 && <section className="media-detail-children"><h2>{detail.kind === 'artist' ? '全部专辑' : '全部内容'}</h2><MediaChildList items={detail.children} label={detail.kind === 'artist' ? '专辑' : '内容'} renderItems={items => <div className="media-grid">{items.map(item => <Link key={item.id} className="media-tile" to={`/media/${channel}/items/${encodeURIComponent(item.id)}`}><MediaCover api={runtime.mediaApi} item={item} square={item.kind === 'artist' || item.kind === 'album'} /><strong title={item.title}>{item.title}</strong></Link>)}</div>} /></section>}
|
||||
</div>{detail.kind !== 'album' && <aside className="media-detail-sidebar media-resource-hidden" />}
|
||||
</div></article></>;
|
||||
}
|
||||
@@ -18,10 +18,8 @@ export function MediaFavoritesPage() {
|
||||
for (const [key, value] of Object.entries(values)) value ? next.set(key, value) : next.delete(key);
|
||||
setParams(next);
|
||||
};
|
||||
return <MediaPageFrame className="media-secondary-page" title="我的收藏">
|
||||
<section className="media-favorites">
|
||||
return <MediaPageFrame className="media-secondary-page" title="我的收藏" backLabel="返回影视">
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{query.data && <MediaFavorites api={runtime.mediaApi} items={query.data.items} total={query.data.total} offset={offset} scope={scope} busy={query.isFetching} onScope={value => patch({ scope: value, offset: '' })} onPage={value => patch({ offset: String(value) })} onOpen={item => navigate(`/media/${favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`, { state: { returnTo: location.pathname + location.search } })} />}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Button, Loader, Select, Stack } from '@mantine/core';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
@@ -14,6 +15,8 @@ export function MediaFoldersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { channel = 'video', libraryId: routeLibraryId } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [actionsHost, setActionsHost] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => { setActionsHost(document.getElementById('media-heading-actions-host')); }, []);
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
const libraries = (query.data?.items ?? []).filter(item => item.kind === channel);
|
||||
@@ -24,6 +27,6 @@ export function MediaFoldersPage() {
|
||||
const location: FolderLocation = { path: params.get('path') ?? '', offset: Number(params.get('offset')) || 0, assetId: params.get('asset') || null, editions: {}, chapters: {} };
|
||||
const updateLocation = (next: FolderLocation) => { const values = new URLSearchParams(params); for (const [key, value] of [['path', next.path], ['offset', String(next.offset)], ['asset', next.assetId ?? '']]) value ? values.set(key, value) : values.delete(key); setParams(values, { replace: true }); };
|
||||
const play = async (parts: Part[], index: number, title: string) => { runtime.player.play(parts.map(part => ({ part, title: `${title} · ${part.title}`, video: channel === 'video' })), index); navigate(`/media/${channel}/player`); };
|
||||
return <MediaPageFrame className="media-secondary-page" title="文件夹浏览"><Stack className="media-folders-page"><div className="media-toolbar media-folder-toolbar"><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>返回频道</Button><Select aria-label="媒体库" value={libraryId} data={libraries.map(item => ({ value: item.id, label: item.name }))} onChange={value => { if (value) navigate(`/media/${channel}/folders/${encodeURIComponent(value)}`); }} /></div><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{!query.isPending && !query.error && requestedLibraryId && !libraryId && <Alert color="red">该媒体库不存在,或不属于当前频道。</Alert>}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && <Alert>当前频道没有可浏览的媒体库。</Alert>}{libraryId && <MediaFolders api={runtime.mediaApi} libraryId={libraryId} admin={role === 'admin'} video={channel === 'video'} initialLocation={location} onLocationChange={updateLocation} onPlay={play} onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onDetail={id => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)} />}</Stack></MediaPageFrame>;
|
||||
return <MediaPageFrame className="media-secondary-page" title="文件夹浏览" backLabel="返回分类"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{!query.isPending && !query.error && requestedLibraryId && !libraryId && <Alert color="red">该媒体库不存在,或不属于当前频道。</Alert>}{!query.isPending && !query.error && !requestedLibraryId && !libraryId && <Alert>当前频道没有可浏览的媒体库。</Alert>}{libraryId && <MediaFolders api={runtime.mediaApi} libraryId={libraryId} admin={role === 'admin'} video={channel === 'video'} initialLocation={location} onLocationChange={updateLocation} onPlay={play} onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onDetail={id => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)} actionsHost={actionsHost} />}</MediaPageFrame>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pagination, Text } from '@mantine/core';
|
||||
import { Button } from '@mantine/core';
|
||||
import { Clock3, Play } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
@@ -21,10 +21,14 @@ export function MediaHistoryPage() {
|
||||
return <MediaPageFrame className="media-secondary-page" title="播放历史" subtitle={channelLabels[channel]}><section className="media-history-page"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} />
|
||||
<div className="media-history-list">{query.data?.items.map((row, index, rows) => <div key={row.id || row.partId}>
|
||||
{(index === 0 || historyDay(row.updatedAt) !== historyDay(rows[index - 1]?.updatedAt)) && <h2 className="media-history-date">{historyDay(row.updatedAt)}</h2>}
|
||||
<article className="media-row media-history-row"><div><Text className="media-personal-title" component={Link} to={`/media/${channel}/items/${row.itemId}`} state={{ returnTo: location.pathname + location.search }} fw={600}>{row.title}</Text><Text size="sm" c="dimmed">{[row.partTitle, row.editionLabel].filter(Boolean).join(' · ')}{row.completed ? ' · 已完成' : row.position !== undefined ? ` · ${historyPosition(row.position, row.start)}` : ''}</Text>{!row.available && <Text size="sm" c="red">资源不可用</Text>}</div><button className="media-history-play" aria-label="播放" title={row.available ? '播放' : '资源不可用'} disabled={!row.available || play.isPending} onClick={() => play.mutate(row)}><Play size={18} aria-hidden="true" /></button></article>
|
||||
<article className="media-row media-history-row"><div><Link className="media-personal-title" to={`/media/${channel}/items/${row.itemId}`} state={{ returnTo: location.pathname + location.search }}>{row.title}</Link><small>{[row.partTitle, row.editionLabel].filter(Boolean).join(' · ')}{row.completed ? ' · 已完成' : row.position !== undefined ? ` · ${historyPosition(row.position, row.start)}` : ''}</small>{!row.available && <small className="media-history-unavailable">资源不可用</small>}</div><button className="media-history-play" aria-label="播放" title={row.available ? '播放' : '资源不可用'} disabled={!row.available || play.isPending} onClick={() => play.mutate(row)}><Play size={18} aria-hidden="true" /></button></article>
|
||||
</div>)}</div>
|
||||
{query.data && query.data.items.length === 0 && <div className="media-personal-empty"><Clock3 size={32} strokeWidth={1.4} aria-hidden="true" /><p>暂无记录。</p></div>}
|
||||
{query.data && <div className="media-toolbar media-pagination"><Pagination total={Math.max(1, Math.ceil(query.data.total / 60))} value={Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} onChange={page => setParams({ offset: String((page - 1) * 60) })} /></div>}
|
||||
{query.data && query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
|
||||
<Button disabled={Number(params.get('offset') ?? 0) <= 0 || query.isFetching} onClick={() => setParams({ offset: Math.max(0, Number(params.get('offset') ?? 0) - 60) ? String(Math.max(0, Number(params.get('offset') ?? 0) - 60)) : '' })}>上一页</Button>
|
||||
<span>{Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
|
||||
<Button disabled={Number(params.get('offset') ?? 0) + 60 >= query.data.total || query.isFetching} onClick={() => setParams({ offset: String(Number(params.get('offset') ?? 0) + 60) })}>下一页</Button>
|
||||
</nav>}
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Card, Loader, Stack, Text, Title } from '@mantine/core';
|
||||
import { Alert, Button, Card, Stack, Text, Title } from '@mantine/core';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
@@ -7,14 +7,13 @@ import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { detailQuery } from '../queries/media.queries.ts';
|
||||
import { useMediaDetailUpdate } from '../mutations/media.mutations.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
import { MetadataEditor } from '../components/metadata-editor.tsx';
|
||||
import { MetadataMatcher } from '../components/metadata-matcher.tsx';
|
||||
import { AudiobookChapters } from '../components/audiobook-chapters.tsx';
|
||||
import { EditionDetails, type ChapterPosition } from '../components/edition-details.tsx';
|
||||
import { EditionTools } from '../components/edition-details.tsx';
|
||||
import { ArtistInfo } from '../components/artist-info.tsx';
|
||||
import { VideoHierarchyEditor } from '../components/video-hierarchy-editor.tsx';
|
||||
import { MusicParentEditor } from '../components/parent-editor.tsx';
|
||||
import { MediaMetadataPage } from '../components/metadata-page.tsx';
|
||||
import type { Detail, MediaChannel, Part } from '../api/media-api.ts';
|
||||
|
||||
const channels = ['video', 'music', 'audiobook'] as const;
|
||||
@@ -32,17 +31,17 @@ function useItemPage() {
|
||||
return { runtime, navigate, channel, itemId, editionId, query, updateDetail };
|
||||
}
|
||||
|
||||
function ItemPageFrame({ title, channel, itemId, children, admin = false }: {
|
||||
title: string; channel: MediaChannel; itemId: string; children: React.ReactNode; admin?: boolean;
|
||||
function ItemPageFrame({ title, channel, itemId, children, admin = false, className }: {
|
||||
title: string; channel: MediaChannel; itemId: string; children: React.ReactNode; admin?: boolean; className?: string;
|
||||
}) {
|
||||
return <MediaPageFrame title={title} actions={<Button component={Link} to={`/media/${channel}/items/${encodeURIComponent(itemId)}`} variant="subtle">返回作品详情</Button>}>
|
||||
{admin && <Text size="sm" c="dimmed">管理员操作会保留文件、版本和播放进度;保存后可返回作品详情继续核对。</Text>}
|
||||
return <MediaPageFrame {...(className ? { className } : {})} title={title} hideHeader={className === 'media-metadata-workspace'} actions={className === 'media-metadata-workspace' ? undefined : <Button component={Link} to={`/media/${channel}/items/${encodeURIComponent(itemId)}`} variant="subtle">返回作品详情</Button>}>
|
||||
{admin && className !== 'media-metadata-workspace' && <Text size="sm" c="dimmed">管理员操作会保留文件、版本和播放进度;保存后可返回作品详情继续核对。</Text>}
|
||||
{children}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
function ItemQuery({ query, children }: { query: ReturnType<typeof useQuery<Detail>>; children: (detail: Detail) => React.ReactNode }) {
|
||||
return <><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{query.data && children(query.data)}{!query.isPending && !query.error && !query.data && <Alert color="red">找不到这个作品。</Alert>}</>;
|
||||
return <><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.data && children(query.data)}{!query.isPending && !query.error && !query.data && <Alert color="red">找不到这个作品。</Alert>}</>;
|
||||
}
|
||||
|
||||
function AdminOnly({ children }: { children: React.ReactNode }) {
|
||||
@@ -56,21 +55,21 @@ function playParts(runtime: ReturnType<typeof useRuntime>, channel: MediaChannel
|
||||
|
||||
export function MediaMetadataEditPage() {
|
||||
const { runtime, navigate, channel, itemId, query, updateDetail } = useItemPage();
|
||||
return <ItemPageFrame title="编辑资料" channel={channel} itemId={itemId} admin><AdminOnly><ItemQuery query={query}>{detail => {
|
||||
return <ItemPageFrame title="编辑资料" channel={channel} itemId={itemId} admin className="media-metadata-workspace"><AdminOnly><ItemQuery query={query}>{detail => {
|
||||
const edition = detail.editions[0];
|
||||
return <Stack>
|
||||
<MetadataEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} />
|
||||
return <div className="media-metadata-host">
|
||||
<MediaMetadataPage api={runtime.mediaApi} item={detail} initialView="edit" onViewChange={view => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}/${view === 'edit' ? 'metadata' : 'match'}`)} onUpdated={updateDetail} onBack={() => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}`)} />
|
||||
{edition && <Card withBorder><Title order={3}>版本与资源管理</Title><EditionTools api={runtime.mediaApi} item={detail} edition={edition} busy={false} onPlay={(parts, index) => { void playParts(runtime, channel, detail.title, parts, index); }} onQueue={ids => { void runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }} onUpdated={updateDetail} onAssigned={target => navigate(`/media/${channel}/items/${encodeURIComponent(target.id)}`)} onRename={() => { void query.refetch(); }} /></Card>}
|
||||
<ArtistInfo item={detail} />
|
||||
<VideoHierarchyEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} />
|
||||
<MusicParentEditor api={runtime.mediaApi} item={detail} onUpdated={updateDetail} />
|
||||
</Stack>;
|
||||
</div>;
|
||||
}}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
}
|
||||
|
||||
export function MediaMatchPage() {
|
||||
const { runtime, channel, itemId, query, updateDetail } = useItemPage();
|
||||
return <ItemPageFrame title="匹配作品" channel={channel} itemId={itemId} admin><AdminOnly><ItemQuery query={query}>{detail => <MetadataMatcher api={runtime.mediaApi} item={detail} layout="page" onUpdated={updateDetail} />}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
const { runtime, navigate, channel, itemId, query, updateDetail } = useItemPage();
|
||||
return <ItemPageFrame title="匹配作品" channel={channel} itemId={itemId} admin className="media-metadata-workspace"><AdminOnly><ItemQuery query={query}>{detail => <div className="media-metadata-host"><MediaMetadataPage api={runtime.mediaApi} item={detail} initialView="match" onViewChange={view => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}/${view === 'edit' ? 'metadata' : 'match'}`)} onUpdated={updateDetail} onBack={() => navigate(`/media/${channel}/items/${encodeURIComponent(itemId)}`)} /></div>}</ItemQuery></AdminOnly></ItemPageFrame>;
|
||||
}
|
||||
|
||||
export function MediaChaptersPage() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Loader } from '@mantine/core';
|
||||
import { MoreHorizontal, RefreshCw, Plus } from 'lucide-react';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { MoreHorizontal, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
@@ -28,9 +28,10 @@ export function MediaLibrariesPage() {
|
||||
const [actionError, setActionError] = useState('');
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
if (role !== 'admin') return <MediaPageFrame title="媒体库管理"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
// Management is scoped to the channel in the URL. Do not expose libraries
|
||||
// from another media channel in this page.
|
||||
const libraries = (query.data?.items ?? []).filter(item => item.kind === channel);
|
||||
// The legacy manager is a shared workspace: opening it from any channel
|
||||
// shows every media library, while the channel only controls the return path
|
||||
// and the default type when creating a new library.
|
||||
const libraries = query.data?.items ?? [];
|
||||
const scan = async (id: string) => {
|
||||
if (busyId) return;
|
||||
setBusyId(id); setActionError('');
|
||||
@@ -42,16 +43,15 @@ export function MediaLibrariesPage() {
|
||||
setActionError(error instanceof Error ? error.message : '扫描请求失败,请稍后重试。');
|
||||
} finally { setBusyId(null); }
|
||||
};
|
||||
return <MediaPageFrame title="媒体库管理" backTo={`/media/${channel}/settings`} backLabel="返回影音设置" actions={<details className="media-actions media-library-management-actions">
|
||||
return <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={`/media/${channel}/settings`} backLabel="返回影音设置" actions={<details className="media-actions media-library-management-actions">
|
||||
<summary aria-label="媒体库管理操作" title="媒体库管理操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="媒体库管理操作">
|
||||
<Link to={`/media/${channel}/settings/libraries/new`}><Plus size={16} aria-hidden="true" />新建媒体库</Link>
|
||||
<button type="button" onClick={() => void query.refetch()}><RefreshCw size={16} aria-hidden="true" />刷新</button>
|
||||
<Link to={`/media/${channel}/settings/tasks`}><RefreshCw size={16} aria-hidden="true" />扫描与刮削</Link>
|
||||
</nav>
|
||||
</details>}>
|
||||
{actionError && <Alert color="red" title="操作失败">{actionError}</Alert>}
|
||||
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
|
||||
{query.isPending && <Loader />}
|
||||
{!query.isPending && !query.error && <MediaLibraryList
|
||||
api={runtime.mediaApi}
|
||||
libraries={libraries}
|
||||
@@ -75,10 +75,11 @@ export function MediaLibraryCreatePage() {
|
||||
const role = useAuthStore(state => state.verifiedUser?.role);
|
||||
if (role !== 'admin') return <MediaPageFrame title="新建媒体库"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
|
||||
const mediaChannel = channel;
|
||||
return <MediaPageFrame title="新建媒体库" backTo={`/media/${channel}/settings/libraries`} backLabel="返回媒体库管理"><MediaLibraryCreate
|
||||
return <MediaPageFrame className="media-manager-workspace" title="新建媒体库" backTo={`/media/${channel}/settings/libraries`} backLabel="返回媒体库管理"><MediaLibraryCreate
|
||||
api={runtime.mediaApi}
|
||||
channel={mediaChannel}
|
||||
disabled={false}
|
||||
hideHeader
|
||||
onCreated={library => {
|
||||
queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => ({ items: [...(value?.items ?? []).filter(item => item.id !== library.id), library] }));
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Loader } from '@mantine/core';
|
||||
import { Alert } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -29,9 +29,9 @@ export function MediaLibraryEditPage() {
|
||||
const { channel: routeChannel, libraryId = '' } = useParams();
|
||||
const channel = channelOf(routeChannel);
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId && item.kind === channel);
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame title="编辑媒体库" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="编辑媒体库"><MediaPageFrame className="media-manager-workspace" title="编辑媒体库" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaLibraryEditor api={runtime.mediaApi} library={library} onSaved={updated => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === updated.id ? updated : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
export function MediaLibraryPermissionsPage() {
|
||||
@@ -41,9 +41,9 @@ export function MediaLibraryPermissionsPage() {
|
||||
const { channel: routeChannel, libraryId = '' } = useParams();
|
||||
const channel = channelOf(routeChannel);
|
||||
const query = useQuery(librariesQuery(runtime));
|
||||
const library = query.data?.items.find(item => item.id === libraryId && item.kind === channel);
|
||||
const library = query.data?.items.find(item => item.id === libraryId);
|
||||
const back = `/media/${channel}/settings/libraries`;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame title="访问权限" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{query.isPending && <Loader />}{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
return <AdminOnly title="访问权限"><MediaPageFrame className="media-manager-workspace" title="访问权限" backTo={back} backLabel="返回媒体库管理"><QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />{library && <MediaPermissions api={runtime.mediaApi} library={library} onSaved={access => { queryClient.setQueryData(librariesQuery(runtime).queryKey, (value: { items: Library[] } | undefined) => value ? { items: value.items.map(item => item.id === library.id ? { ...item, access } : item) } : value); navigate(back); }} onCancel={() => navigate(back)} />}{!query.isPending && !query.error && !library && <Alert color="red">媒体库不存在。</Alert>}</MediaPageFrame></AdminOnly>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Alert, Loader, Select } from '@mantine/core';
|
||||
import { Button } from '@mantine/core';
|
||||
import { History, LibraryBig, ListVideo, MoreHorizontal, Search, Settings2, Star } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
@@ -7,6 +9,8 @@ import { librariesQuery } from '../queries/media.queries.ts';
|
||||
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
|
||||
import { Narrators, type NarratorLocation } from '../components/narrators.tsx';
|
||||
import type { MediaChannel, Part } from '../api/media-api.ts';
|
||||
import { MediaSelect } from '../components/select.tsx';
|
||||
import { EmptyMediaLibrary } from '../components/empty-library.tsx';
|
||||
|
||||
/**
|
||||
* Dedicated narrator workspace. Narrators are backed by the specialised
|
||||
@@ -60,10 +64,22 @@ export function MediaNarratorsPage() {
|
||||
await runtime.player.play(parts.map(part => ({ part, title: `${title} · ${part.title}`, video: false })), index);
|
||||
};
|
||||
|
||||
return <MediaPageFrame title="演播者">
|
||||
const actions = <>
|
||||
<Button component={Link} to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Search size={19} aria-hidden="true" /></Button>
|
||||
<details className="media-actions media-catalog-actions">
|
||||
<summary aria-label="更多操作" title="更多操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
|
||||
<nav aria-label="更多操作">
|
||||
<Link to="/media/audiobook/settings"><Settings2 size={16} aria-hidden="true" />影音设置</Link>
|
||||
<Link to="/media/favorites"><Star size={16} aria-hidden="true" />收藏</Link>
|
||||
<Link to="/media/audiobook/history"><History size={16} aria-hidden="true" />历史</Link>
|
||||
<Link to="/media/audiobook/queue"><ListVideo size={16} aria-hidden="true" />队列</Link>
|
||||
<Link to="/media/audiobook/settings/libraries"><LibraryBig size={16} aria-hidden="true" />媒体库管理</Link>
|
||||
</nav>
|
||||
</details>
|
||||
</>;
|
||||
return <MediaPageFrame className="media-catalog-page" title="有声书" actions={actions}>
|
||||
<QueryFeedback pending={libraries.isPending} error={libraries.error} retry={() => { void libraries.refetch(); }} />
|
||||
{libraries.isPending && <Loader />}
|
||||
{!libraries.isPending && !libraries.error && !libraryId && <Alert>当前没有可浏览的有声书媒体库。</Alert>}
|
||||
{!libraries.isPending && !libraries.error && !libraryId && <EmptyMediaLibrary channel="audiobook" hasLibraries={false} admin={role === 'admin'} category="演播者" onCreate={() => navigate('/media/audiobook/settings/libraries/new')} onManage={() => navigate('/media/audiobook/settings/libraries')} />}
|
||||
{!libraries.isPending && !libraries.error && libraryId && <Narrators
|
||||
key={`${routeName ?? ''}:${routeWork}`}
|
||||
api={runtime.mediaApi}
|
||||
@@ -71,16 +87,16 @@ export function MediaNarratorsPage() {
|
||||
showLibraryName={!requested}
|
||||
initialLocation={initialLocation}
|
||||
onLocationChange={updateLocation}
|
||||
libraryControl={<Select aria-label="媒体库" value={libraryId} data={available.map(item => ({ value: item.id, label: item.name }))} onChange={value => {
|
||||
libraryControl={<MediaSelect variant="plain" aria-label="媒体库" value={libraryId} onChange={event => {
|
||||
const value = event.currentTarget.value;
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set('library', value); else next.delete('library');
|
||||
for (const key of ['narrator', 'work', 'edition', 'offset']) next.delete(key);
|
||||
navigate(`/media/audiobook/narrators${next.toString() ? `?${next.toString()}` : ''}`, { replace: true });
|
||||
}} />}
|
||||
}}>{available.map(item => <option value={item.id} key={item.id}>{item.name}</option>)}</MediaSelect>}
|
||||
onPlay={play}
|
||||
onQueue={async ids => { await runtime.mediaApi.request('queue', 'POST', { partIds: ids }); }}
|
||||
onDetail={id => navigate(`/media/audiobook/items/${encodeURIComponent(id)}`)}
|
||||
/>}
|
||||
{role === 'admin' && !libraries.isPending && !libraries.error && available.length === 0 && <Alert color="yellow">管理员可以先在影音设置中创建有声书媒体库。</Alert>}
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Center, Stack, Title } from '@mantine/core';
|
||||
import { Button, Center, Stack } from '@mantine/core';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
@@ -41,14 +41,22 @@ export function MediaPlayerPage() {
|
||||
.catch(reason => { if (!controller.signal.aborted) setRestoreError(reason instanceof Error ? reason.message : '无法恢复播放'); });
|
||||
return () => controller.abort();
|
||||
}, [runtime, params]);
|
||||
const back = () => navigate(-1);
|
||||
// Legacy playback keeps the expanded player open when leaving queue/lyrics/
|
||||
// chapter sub-panels; only the main player view exits back to the catalogue.
|
||||
const back = () => {
|
||||
if (requested !== 'main') {
|
||||
navigate(`/media/${channel}/player`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(-1);
|
||||
};
|
||||
return <div className={rootClassName}>
|
||||
{videoPage && <header className="media-heading">
|
||||
<button type="button" className="media-back-button" aria-label="← 返回浏览" title="返回浏览" onClick={back}><ChevronLeft size={20} aria-hidden="true" /></button>
|
||||
<strong>视频播放</strong>
|
||||
</header>}
|
||||
{restoreError ? <Center mih="60vh"><Stack align="center"><Title order={2}>无法恢复播放</Title><div role="alert">{restoreError}</div><Button onClick={() => navigate(`/media/${channel}`)}>返回频道</Button></Stack></Center>
|
||||
: !active ? <Center mih="60vh"><Stack align="center"><Title order={2}>当前没有播放内容</Title><Button onClick={() => navigate(`/media/${channel}`)}>选择作品</Button></Stack></Center>
|
||||
{restoreError ? <Center mih="60vh"><Stack align="center"><strong>无法恢复播放</strong><div role="alert">{restoreError}</div><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>返回频道</Button></Stack></Center>
|
||||
: !active ? <div className="media-player-empty"><p>当前没有播放内容。</p><Button variant="subtle" onClick={() => navigate(`/media/${channel}`)}>选择作品</Button></div>
|
||||
: video ? createPortal(<VideoControls player={runtime.player} api={runtime.mediaApi} onBack={back} />, runtime.player.videoControlsHost)
|
||||
: <PlaybackControls player={runtime.player} api={runtime.mediaApi} panel={requested} onPanelChange={value => navigate(value === 'main' ? `/media/${channel}/player` : `/media/${channel}/player/${value}`, { replace: true })} onBack={back} />}
|
||||
</div>;
|
||||
|
||||
@@ -48,7 +48,8 @@ export function MediaQueuePage() {
|
||||
}
|
||||
if (type === 'clear') {
|
||||
if (channel) {
|
||||
await runtime.mediaApi.request('queue/clear', 'POST', { channel, entryIds: entries.map(entry => entry.id) });
|
||||
const channelEntries = entries.filter(entry => queueChannel(entry.kind) === channel);
|
||||
await runtime.mediaApi.request('queue/clear', 'POST', { channel, entryIds: channelEntries.map(entry => entry.id) });
|
||||
} else {
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const entry of entries) { const kind = queueChannel(entry.kind), ids = grouped.get(kind) ?? []; ids.push(entry.id); grouped.set(kind, ids); }
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { SettingsPanel } from '../components/settings.tsx';
|
||||
import { readMediaPreferences } from '../services/preferences.ts';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
|
||||
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'plugins', 'account'];
|
||||
const panels: readonly SettingsPanel[] = ['home', 'theme', 'browse', 'playback', 'account'];
|
||||
const titles: Record<SettingsPanel, string> = {
|
||||
home: '影音设置', theme: '主题外观', browse: '浏览偏好', playback: '播放设置',
|
||||
plugins: '来源与刮削', account: '账号与连接',
|
||||
@@ -41,7 +41,7 @@ export function MediaSettingsPage() {
|
||||
onSaved={setPreferences}
|
||||
onBack={() => navigate(`/media/${channel}`)}
|
||||
onPanelChange={next => navigate(settingsPath(next))}
|
||||
onPersonal={view => navigate(view === 'favorites' ? `/media/${channel}/favorites` : view === 'queue' ? `/media/${channel}/queue` : `/media/${channel}/history`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onPersonal={view => navigate(view === 'favorites' ? '/media/favorites' : view === 'queue' ? `/media/${channel}/queue` : `/media/${channel}/history`, { state: { returnTo: `/media/${channel}/settings` } })}
|
||||
onManage={() => navigate(`/media/${channel}/settings/libraries`)}
|
||||
onTasks={() => navigate(`/media/${channel}/settings/tasks`)}
|
||||
onPlayback={() => navigate(`/media/${channel}/player`)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Group, Loader, SegmentedControl, Stack, Text } from '@mantine/core';
|
||||
import { Alert, Button, Group, Stack, Text } from '@mantine/core';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
@@ -9,6 +9,8 @@ import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx'
|
||||
import { ScanJobs } from '../components/scan-jobs.tsx';
|
||||
import { AiScanJobs } from '../components/ai-scan-jobs.tsx';
|
||||
import { ScrapeJobs } from '../components/scrape-jobs.tsx';
|
||||
import { MediaSelect } from '../components/select.tsx';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { MediaChannel } from '../api/media-api.ts';
|
||||
|
||||
export function MediaTasksPage() {
|
||||
@@ -24,13 +26,7 @@ export function MediaTasksPage() {
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const tab = params.get('tab') === 'ai' || params.get('tab') === 'scrape' ? params.get('tab')! : 'scan';
|
||||
const requestedLibraryId = params.get('library') ?? '';
|
||||
const librariesQueryResult = useQuery(librariesQuery(runtime));
|
||||
const libraries = {
|
||||
...librariesQueryResult,
|
||||
data: librariesQueryResult.data
|
||||
? { items: librariesQueryResult.data.items.filter(item => item.kind === channel) }
|
||||
: undefined,
|
||||
};
|
||||
const libraries = useQuery(librariesQuery(runtime));
|
||||
const libraryId = libraries.data?.items.some(item => item.id === requestedLibraryId)
|
||||
? requestedLibraryId
|
||||
: '';
|
||||
@@ -50,9 +46,8 @@ export function MediaTasksPage() {
|
||||
if (id) {
|
||||
await runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST');
|
||||
} else {
|
||||
// Submit one request per library in the active channel. The legacy
|
||||
// bulk endpoint scans every channel and would violate this route's
|
||||
// boundary.
|
||||
// The legacy task workspace scans every media library, regardless of
|
||||
// which channel opened the settings route.
|
||||
await Promise.all((libraries.data?.items ?? []).map(library =>
|
||||
runtime.mediaApi.request(`libraries/${encodeURIComponent(library.id)}/scan`, 'POST')));
|
||||
}
|
||||
@@ -64,19 +59,19 @@ export function MediaTasksPage() {
|
||||
async () => { await runtime.mediaApi.startAiScan(id); await aiJobs.refetch(); },
|
||||
id ? '已创建当前媒体库的 AI 扫描任务。' : '已创建全部媒体库的 AI 扫描任务。',
|
||||
);
|
||||
const channelLibraryIds = new Set((libraries.data?.items ?? []).map(library => library.id));
|
||||
const filteredJobs = (jobs.data?.items ?? []).filter(job =>
|
||||
libraryId ? (!job.libraryId || job.libraryId === libraryId) : (!job.libraryId || channelLibraryIds.has(job.libraryId)));
|
||||
return <MediaPageFrame title="扫描与刮削" backTo={`/media/${channel}/settings`} backLabel="返回影音设置">
|
||||
<Stack>
|
||||
<SegmentedControl value={tab} onChange={setTab} data={[{ value: 'scan', label: '媒体库扫描' }, { value: 'ai', label: 'AI 扫描' }, { value: 'scrape', label: '刮削' }]} />
|
||||
{requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && <Alert color="yellow">所选媒体库不存在,或不属于当前频道;已切换为全部媒体库。</Alert>}
|
||||
libraryId ? (!job.libraryId || job.libraryId === libraryId) : true);
|
||||
return <MediaPageFrame className="media-manager-workspace" title="扫描与刮削" backTo={`/media/${channel}/settings`} backLabel="返回影音设置">
|
||||
<section className="media-manager">
|
||||
<nav className="media-task-tabs" aria-label="任务类型">{[['scan','媒体库扫描'],['ai','AI 扫描'],['scrape','刮削']].map(([value,label]) => <button type="button" key={value} aria-current={tab === value ? 'page' : undefined} onClick={() => setTab(value)}>{label}</button>)}</nav>
|
||||
{requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && <Alert color="yellow">所选媒体库不存在,已切换为全部媒体库。</Alert>}
|
||||
{actionError && <Alert color="red" title="操作失败">{actionError}</Alert>}
|
||||
{actionNotice && <Alert color="green">{actionNotice}</Alert>}
|
||||
<QueryFeedback pending={libraries.isPending || (tab === 'scan' ? jobs.isPending : tab === 'ai' ? aiJobs.isPending : false)} error={libraries.error || (tab === 'scan' ? jobs.error : tab === 'ai' ? aiJobs.error : null)} retry={() => { void libraries.refetch(); void jobs.refetch(); void aiJobs.refetch(); }} />
|
||||
{(libraries.isPending || (tab === 'scan' ? jobs.isPending : tab === 'ai' ? aiJobs.isPending : false)) && <Loader />}
|
||||
{tab === 'scan' && !jobs.isPending && !jobs.error && <Stack>
|
||||
<Group justify="space-between"><div><Text fw={600}>媒体库扫描</Text><Text size="sm" c="dimmed">扫描目录并更新媒体索引。</Text></div><Button loading={actionBusy} disabled={!libraries.data?.items.length} onClick={() => { void startScan(libraryId || undefined); }}>扫描{libraryId ? '当前库' : '所有媒体库'}</Button></Group>
|
||||
<div className="media-task-intro"><div><Text component="h2">媒体库扫描</Text><Text component="p">扫描所有媒体库并建立索引</Text></div><Button className="media-primary" loading={actionBusy} disabled={!libraries.data?.items.length} onClick={() => { void startScan(libraryId || undefined); }}>扫描{libraryId ? '当前库' : '所有媒体库'}</Button></div>
|
||||
<div className="media-task-library"><label>媒体库<MediaSelect aria-label="扫描媒体库" value={libraryId} onChange={event => { const next = new URLSearchParams(params); const value = event.currentTarget.value; value ? next.set('library', value) : next.delete('library'); setParams(next); }}><option value="">全部媒体库 · 最新任务</option>{(libraries.data?.items ?? []).map(library => <option key={library.id} value={library.id}>{library.name}</option>)}</MediaSelect></label><Button disabled={!libraryId || actionBusy} onClick={() => { void startScan(libraryId); }}><RefreshCw size={16} aria-hidden="true" />扫描当前库</Button><Button aria-label="刷新扫描任务" title="刷新扫描任务" variant="subtle" onClick={() => { void jobs.refetch(); }}><RefreshCw size={16} aria-hidden="true" /></Button></div>
|
||||
<h2 className="media-task-section-title">媒体库扫描历史</h2>
|
||||
<ScanJobs jobs={filteredJobs} libraries={libraries.data?.items ?? []} libraryName="媒体库" busy={jobs.isFetching || actionBusy} onRetry={id => { void startScan(id || libraryId || undefined); }} onCancel={id => { void runAction(async () => { await runtime.mediaApi.request(`jobs/${encodeURIComponent(id)}/cancel`, 'POST'); await jobs.refetch(); }, '扫描任务已取消。'); }} onDelete={id => { void runAction(async () => { await runtime.mediaApi.request(`scan-jobs/${encodeURIComponent(id)}`, 'DELETE'); await jobs.refetch(); }, '扫描历史已删除。'); }} />
|
||||
</Stack>}
|
||||
{tab === 'ai' && !aiJobs.isPending && !aiJobs.error && <Stack>
|
||||
@@ -84,7 +79,7 @@ export function MediaTasksPage() {
|
||||
<AiScanJobs api={runtime.mediaApi} jobs={(aiJobs.data?.items ?? []).filter(job => !libraryId || job.libraryId === libraryId)} libraries={libraries.data?.items ?? []} busy={aiJobs.isFetching || actionBusy} onRefresh={() => { void aiJobs.refetch(); }} onDelete={id => { void runAction(async () => { await runtime.mediaApi.deleteAiScanJob(id); await aiJobs.refetch(); }, 'AI 扫描历史已删除。'); }} />
|
||||
</Stack>}
|
||||
{tab === 'scrape' && !libraries.isPending && !libraries.error && <ScrapeJobs api={runtime.mediaApi} libraries={libraries.data?.items ?? []} navigate={(mediaChannel: MediaChannel, id: string) => { navigate(`/media/${mediaChannel}/items/${encodeURIComponent(id)}`); }} />}
|
||||
</Stack>
|
||||
</section>
|
||||
</MediaPageFrame>;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,16 @@ export const mediaThemes = [
|
||||
export type MediaThemeId = 'system' | typeof mediaThemes[number]['id'];
|
||||
const tokens=['background','paper','surface','text','muted','border','accent','on-accent','selection'] as const;
|
||||
const storageKey=(scope:string)=>'reader.media.theme.v1:'+scope;
|
||||
const themeChangeEvent='reader-media-theme-change';
|
||||
export function validMediaTheme(value:unknown):MediaThemeId{return value==='system'||mediaThemes.some(theme=>theme.id===value)?value as MediaThemeId:'system';}
|
||||
export function readMediaTheme(scope:string):MediaThemeId{try{return validMediaTheme(localStorage.getItem(storageKey(scope)));}catch{return 'system';}}
|
||||
export function saveMediaTheme(scope:string,value:MediaThemeId){try{localStorage.setItem(storageKey(scope),validMediaTheme(value));}catch{throw new Error('设备无法保存主题,请检查浏览器存储空间或隐私设置。');}}
|
||||
export function saveMediaTheme(scope:string,value:MediaThemeId){
|
||||
const next=validMediaTheme(value);
|
||||
try{localStorage.setItem(storageKey(scope),next);}catch{throw new Error('设备无法保存主题,请检查浏览器存储空间或隐私设置。');}
|
||||
// Storage events do not fire in the tab that made the change. Notify the
|
||||
// active media shell explicitly so a theme choice is applied immediately.
|
||||
if(typeof window!=='undefined') window.dispatchEvent(new CustomEvent(themeChangeEvent,{detail:{scope,value:next}}));
|
||||
}
|
||||
export function mediaThemePalette(id:MediaThemeId,dark=false){return mediaThemes.find(theme=>theme.id===(id==='system'?(dark?'graphite':'forest'):id))!;}
|
||||
|
||||
/** Only prefixed media variables are placed on the body; reading tokens remain untouched. */
|
||||
@@ -22,10 +29,15 @@ export class MediaThemeController {
|
||||
this.current=readMediaTheme(scope);this.apply();
|
||||
this.system?.addEventListener('change',this.onSystem);
|
||||
window.addEventListener('storage',this.onStorage);
|
||||
window.addEventListener(themeChangeEvent,this.onThemeChange);
|
||||
}
|
||||
set(value:MediaThemeId){this.current=validMediaTheme(value);this.apply();this.onChanged();}
|
||||
private onSystem=()=>{if(this.current==='system'){this.apply();this.onChanged();}};
|
||||
private onStorage=(event:StorageEvent)=>{if(event.key===null||event.key===storageKey(this.scope))this.set(readMediaTheme(this.scope));};
|
||||
private onThemeChange=(event:Event)=>{
|
||||
const detail=(event as CustomEvent<{scope?:unknown;value?:unknown}>).detail;
|
||||
if(detail?.scope===this.scope)this.set(validMediaTheme(detail.value));
|
||||
};
|
||||
private apply(){
|
||||
const theme=mediaThemePalette(this.current,this.system?.matches);
|
||||
document.body.dataset.mediaTheme=this.current;
|
||||
@@ -35,7 +47,7 @@ export class MediaThemeController {
|
||||
document.body.style.setProperty('--media-theme-error-background',theme.dark?'#f49a8c18':'#a4504515');
|
||||
}
|
||||
dispose(){
|
||||
this.system?.removeEventListener('change',this.onSystem);window.removeEventListener('storage',this.onStorage);
|
||||
this.system?.removeEventListener('change',this.onSystem);window.removeEventListener('storage',this.onStorage);window.removeEventListener(themeChangeEvent,this.onThemeChange);
|
||||
delete document.body.dataset.mediaTheme;
|
||||
tokens.forEach(token=>document.body.style.removeProperty('--media-theme-'+token));
|
||||
for(const token of ['scheme','error-ink','error-background'])document.body.style.removeProperty('--media-theme-'+token);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
.media-screen .media-favorites {width:100%;max-width:850px;margin:0 auto}
|
||||
.media-favorites .media-favorite-tools {display:flex;align-items:center;justify-content:space-between;gap:8px;margin:14px 0 6px}
|
||||
.media-favorites .media-favorite-tools p {margin:0;font-size:12px;color:var(--muted)}
|
||||
.media-screen .media-favorite-tools button {display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--media-accent);font-size:12px;min-height:44px}
|
||||
.media-screen .media-favorite-filter-menu {position:relative;display:inline-block;color:var(--media-accent);font-size:12px}
|
||||
.media-screen .media-favorite-filter-menu>summary {display:inline-flex;align-items:center;gap:6px;min-height:44px;padding:0;border:0;border-radius:0;background:transparent;color:inherit;cursor:pointer;list-style:none;white-space:nowrap}
|
||||
.media-screen .media-favorite-filter-menu>summary::-webkit-details-marker {display:none}
|
||||
.media-screen .media-favorite-filter-menu>summary:hover {color:var(--text)}
|
||||
.media-screen .media-favorite-filter-menu>summary:focus-visible {outline:2px solid var(--media-accent);outline-offset:3px;border-radius:4px}
|
||||
.media-screen .media-favorite-filter-options {position:absolute;z-index:40;top:calc(100% + 4px);right:0;display:grid;min-width:148px;padding:5px;border:1px solid var(--border);border-radius:10px;background:var(--media-paper,var(--surface));box-shadow:0 8px 24px #0002}
|
||||
.media-screen .media-favorite-filter-options button {display:flex;align-items:center;justify-content:space-between;gap:14px;width:100%;min-height:40px;padding:9px 10px;border:0;border-radius:7px;background:transparent;color:var(--text);font:inherit;font-size:12px;line-height:1.4;text-align:left;cursor:pointer}
|
||||
.media-screen .media-favorite-filter-options button:hover,.media-screen .media-favorite-filter-options button:focus-visible {background:var(--media-selection);color:var(--media-accent);outline:0}
|
||||
.media-screen .media-favorite-filter-options button[aria-selected=true] {color:var(--media-accent);font-weight:600}
|
||||
.media-screen .media-favorite-filter-options button svg {flex:none;color:var(--media-accent)}
|
||||
.media-screen .media-favorites .media-favorite-list {max-width:none}
|
||||
.media-screen .media-favorites .media-favorite-row {display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;text-align:left;color:var(--text)}
|
||||
.media-screen .media-favorites .media-favorite-row:hover {background:var(--surface)}
|
||||
@@ -12,7 +21,3 @@
|
||||
.media-favorites .media-favorite-copy strong {display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:13px;line-height:1.5;font-weight:550;margin:0}
|
||||
.media-favorites .media-favorite-copy small {display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:11px;line-height:1.5;color:var(--muted);margin-top:3px}
|
||||
.media-favorites .media-favorite-row>svg {flex:none;color:var(--muted)}
|
||||
.media-screen .media-favorite-filter {padding:0 16px 16px}
|
||||
.media-screen .media-favorite-filter button {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:58px;padding:12px 0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;font-size:13px;color:var(--text);text-align:left}
|
||||
.media-screen .media-favorite-filter button:last-child {border-bottom:0}
|
||||
.media-screen .media-favorite-filter button[aria-pressed=true] {color:var(--media-accent)}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Theme aliases are scoped to media roots; reading tokens are consumed, never overwritten. */
|
||||
.media-screen,.media-player{--text:var(--reader-ink);--surface:var(--reader-surface);--border:var(--reader-line);--media-accent:#4d682f;--media-on-accent:#fff;--media-selection:#e2ead8;--media-error-background:#a4504515;--media-error-ink:#943c32;color:var(--text);color-scheme:light}
|
||||
.media-screen,.media-player{--text:var(--media-theme-text,var(--reader-ink));--surface:var(--media-theme-surface,var(--reader-surface));--border:var(--media-theme-border,var(--reader-line));--media-accent:var(--media-theme-accent,#4d682f);--media-on-accent:var(--media-theme-on-accent,#fff);--media-selection:var(--media-theme-selection,#e2ead8);--media-error-background:var(--media-theme-error-background,#a4504515);--media-error-ink:var(--media-theme-error-ink,#943c32);color:var(--text);color-scheme:var(--media-theme-scheme,light)}
|
||||
[data-theme=dark] .media-screen,[data-theme=dark] .media-player{--media-accent:#adc994;--media-on-accent:#18240e;--media-selection:#2c3826;--media-error-background:#f49a8c18;--media-error-ink:#f4a89d;color-scheme:dark}
|
||||
.media-screen{max-width:1100px;margin:0 auto;padding:20px 24px 100px;color:var(--text,#30392c)}.media-screen button,.media-screen input,.media-screen select,.media-screen textarea{font:inherit;color:inherit;background:var(--surface,#f0f0e8);border:1px solid var(--border,#dce2d4);border-radius:7px;padding:9px 12px;min-height:40px}.media-screen button{cursor:pointer}.media-screen button:disabled{opacity:.45;cursor:default}.media-screen h1{font-size:23px}.media-screen h2{font-size:17px;margin-top:24px}.media-heading,.media-toolbar,.media-search,.media-row{display:flex;gap:12px;align-items:center;justify-content:space-between;margin:12px 0}.media-heading h1{margin:0}.media-search input{flex:1;min-width:0}.media-tabs{display:flex;gap:20px;border-bottom:1px solid var(--border,#dce2d4);margin:18px 0}.media-tabs button{border:0;background:transparent;border-radius:0}.media-tabs button[aria-current=page]{border-bottom:3px solid var(--media-accent);color:var(--media-accent)}.media-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:22px 17px}.media-grid .media-tile{padding:0;border:0;background:transparent;text-align:left;min-width:0}.media-cover{display:flex;aspect-ratio:2/3;background:linear-gradient(145deg,#6b8060,#384d43);border-radius:5px 9px 9px 5px;align-items:center;justify-content:center;padding:15px;color:#f5f0df;font:22px/1.7 Georgia,serif;overflow-wrap:anywhere;text-align:center}.media-cover.square{aspect-ratio:1}.media-tile strong,.media-tile small{display:block;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;margin-top:7px}.media-tile strong{font-weight:500;font-size:13px}.media-tile small,.media-row small{font-size:12px;opacity:.65;display:block}.media-hero{display:flex;gap:24px;align-items:center}.media-hero .media-cover{width:140px;min-width:140px}.media-row{border-bottom:1px solid var(--border,#dce2d4);padding:10px 0}.media-row>span{flex:1;min-width:0;overflow-wrap:anywhere}.media-form{display:grid;gap:14px;max-width:560px;margin:20px 0}.media-form label{display:grid;gap:7px}.media-form textarea{min-height:110px}.media-empty{padding:60px 15px;text-align:center;opacity:.7}.media-error{padding:12px;background:var(--media-error-background);color:var(--media-error-ink);border-radius:8px}.media-player{max-width:1100px;margin:0 auto;padding:10px 20px 80px}.media-player[hidden]{display:none}.media-player audio{width:100%}.media-player video{width:100%;max-height:65vh;background:#101510}.media-player [hidden]{display:none}@media(max-width:600px){.media-screen{padding:16px 14px 80px}.media-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:17px 12px}.media-cover{font-size:18px}.media-hero .media-cover{width:105px;min-width:105px}.media-hero{gap:15px}}
|
||||
.media-cover.has-image{padding:0;overflow:hidden}.media-cover img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
@@ -16,8 +16,8 @@
|
||||
.media-screen .media-toolbar{margin:8px 0 12px;font-size:12px}.media-toolbar select{max-width:75%;min-width:0}
|
||||
.media-loading{font-size:12px;opacity:.7;min-height:20px}
|
||||
.media-library-row{flex-wrap:wrap;gap:8px}.media-library-row>span{flex-basis:100%}.media-library-row button{flex:1}
|
||||
.media-history-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.media-history-row>button:first-child{text-align:left;min-width:0;overflow-wrap:anywhere;background:transparent;border-color:transparent;padding:6px 0;line-height:1.5}.media-history-row>button:not(:first-child){white-space:nowrap;min-width:60px}
|
||||
.media-personal-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}.media-history-row>button:first-child small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.media-history-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px}.media-history-row>div{min-width:0;padding:6px 0;line-height:1.5}.media-history-row>button:not(:first-child){white-space:nowrap;min-width:60px}
|
||||
.media-personal-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;color:inherit;text-decoration:none;font-size:13px;font-weight:500;line-height:1.5}.media-history-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:4px;font-size:11px;line-height:1.5}.media-history-unavailable{color:var(--media-error-ink);white-space:normal!important}
|
||||
.media-permission-fields{border:0;padding:0;margin:0;display:grid;gap:12px;min-width:0}.media-permission-fields p{font-size:12px;opacity:.7;margin:0}.media-permission-users{max-height:350px;overflow-y:auto}.media-form .media-user-option{display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--border,#dce2d4)}.media-user-option input{width:20px;min-height:20px;margin:0}.media-user-option small{display:block;opacity:.65;margin-top:3px}
|
||||
@media(max-width:600px){.media-screen{padding:8px 14px 100px}.media-grid{gap:15px 10px}.media-screen .media-toolbar select{padding:5px 8px;min-height:34px}.media-tile strong{margin-top:5px}.media-tile small{margin-top:3px}}
|
||||
.media-subtitle-controls{display:flex;align-items:center;gap:8px;padding:10px 0;font-size:13px}.media-subtitle-controls select{max-width:65%;font:inherit;padding:6px}.media-subtitle-controls span{font-size:12px}
|
||||
@@ -36,6 +36,7 @@
|
||||
.media-continue{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 12px;align-items:center;padding:10px 12px;margin:8px 0;border:1px solid var(--border,#dce2d4);border-radius:10px}.media-continue>div{min-width:0;display:grid;gap:3px}.media-continue small,.media-continue span{font-size:11px;opacity:.7}.media-continue strong,.media-continue span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.media-continue strong{font-size:14px}.media-continue progress{width:100%;height:3px;accent-color:var(--media-accent)}.media-continue>p{grid-column:1/-1;margin:0;font-size:12px}
|
||||
.media-player:not(.media-player-expanded){position:fixed;bottom:80px;left:24px;right:24px;z-index:75;max-width:680px;padding:8px 12px;border:1px solid var(--border,#dce2d4);border-radius:12px;background:var(--surface,#f0f0e8);color:var(--text,#30392c);box-shadow:0 4px 20px #0002}.media-player:not(.media-player-expanded)>:not(.media-mini){display:none}@media(max-width:600px){.media-player:not(.media-player-expanded){bottom:calc(66px + env(safe-area-inset-bottom));left:10px;right:10px;padding:4px 8px;border-radius:10px}}
|
||||
.media-screen{width:100%;box-sizing:border-box}
|
||||
.media-player-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;color:var(--muted,#66715f);font-size:14px}.media-player-empty p{margin:0 0 14px}.media-player-empty button{border:0;background:transparent;padding:6px 10px;min-height:36px}
|
||||
.media-screen{flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain}
|
||||
.media-screen.media-video-page{overflow:hidden;padding-bottom:0}.media-player select,.media-player input,.media-player button{color:inherit}.media-player select{background:var(--surface);border:1px solid var(--border)}
|
||||
.media-season-list{display:grid;gap:6px}.media-season-row{display:grid;grid-template-columns:24px minmax(0,1fr);gap:6px;align-items:center;border-bottom:1px solid var(--border);padding:6px 0}.media-season-row>button{text-align:left;overflow-wrap:anywhere}.media-season-row>select{grid-column:2;min-width:0;width:100%;font-size:12px}.media-season-row>input{min-height:24px;accent-color:var(--media-accent)}.media-season-notice{font-size:12px;line-height:1.5}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
.media-detail-columns {display:block;max-width:820px}
|
||||
.media-detail-sidebar {max-width:none}
|
||||
.media-detail-page .media-detail-description {margin:20px 0}
|
||||
.media-detail-page > .media-detail-actions {margin-top:4px}
|
||||
.media-detail-page > .media-detail-actions {margin-top:20px}
|
||||
.media-detail-description p {font-size:13px;line-height:1.9;margin:8px 0}
|
||||
.media-detail-page .media-resource-panel {margin:22px 0 0;padding:0 14px;border:1px solid var(--border);border-radius:10px;background:var(--surface)}
|
||||
.media-resource-panel>header {min-height:50px;margin:0}
|
||||
@@ -168,8 +168,8 @@
|
||||
.media-video-caption {border:0;padding:0;font-size:18px}
|
||||
.media-video-caption strong {font-weight:650}
|
||||
.media-video-caption small {font-size:12px}
|
||||
.media-video-toolbar.media-video-tools {display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:16px 0;border-block:1px solid var(--border)}
|
||||
.media-video-tools>button {display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:62px;font-size:11px}
|
||||
.media-video-toolbar.media-video-tools {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(84px,100%),1fr));margin:16px 0;border-block:1px solid var(--border)}
|
||||
.media-video-tools>button {display:flex;min-width:0;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:62px;font-size:11px}
|
||||
.media-video-description summary {min-height:44px;cursor:pointer;display:flex;align-items:center}
|
||||
.media-episode-rail {padding:16px;background:var(--media-paper);color:var(--text);border:1px solid var(--border);border-radius:10px;margin-top:20px}
|
||||
.media-episode-rail header {display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
@@ -293,7 +293,7 @@ body[data-media-theme] .media-video-stage .plyr {
|
||||
.media-screen .media-playback-status[data-panel=lyrics] {max-width:560px}
|
||||
.media-playback-status[data-panel=main][data-channel=audiobook] .media-playing-artwork>.media-cover {width:230px;aspect-ratio:2/3}
|
||||
.media-video-toolbar.media-video-tools {max-width:440px}
|
||||
.media-video-tools {grid-template-columns:repeat(auto-fit,minmax(72px,1fr))}
|
||||
.media-video-tools {grid-template-columns:repeat(auto-fit,minmax(min(84px,100%),1fr))}
|
||||
.media-video-tools>button {flex-direction:row;gap:8px;min-height:48px}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,13 @@ body[data-media-theme] .media-video-stage .plyr {
|
||||
white-space: normal;
|
||||
border: 0;
|
||||
}
|
||||
/* Catalog pages without category tabs still reserve the legacy title row. */
|
||||
@media(min-width:701px){
|
||||
.media-screen.media-catalog-page > .media-heading:not(:has(~ .media-tabs)) {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
}
|
||||
}
|
||||
.media-screen.media-catalog-page > .media-heading .media-mobile-title { display: none; }
|
||||
.media-screen.media-catalog-page > .media-heading .media-heading-actions {
|
||||
position: absolute;
|
||||
@@ -790,6 +797,7 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
}
|
||||
:is(.media-screen,.media-player,.media-modal) :is(button,a,input,select,summary):focus-visible {outline:2px solid var(--media-accent);outline-offset:3px}
|
||||
.media-screen .media-grid:not(.media-season-picker) .media-tile strong {white-space:normal;min-height:3em}
|
||||
.media-screen .media-grid .media-tile {text-decoration:none;color:inherit;display:block}
|
||||
.media-screen .media-browse-tools {height:auto;flex-wrap:wrap}
|
||||
.media-loading-status {color:var(--muted);font-size:13px;min-height:24px}
|
||||
.media-scan-job .media-task-retry {grid-column:1/-1;justify-self:start;min-height:44px}
|
||||
@@ -846,8 +854,82 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
.media-screen:has(>.media-read-loading-detail)>.media-heading .media-page-heading h2{margin:0;font-size:16px;line-height:1.4}
|
||||
.media-screen .media-folders{position:relative}
|
||||
.media-screen .media-heading-actions .media-folder-actions{position:relative;margin:0;z-index:4}
|
||||
.media-screen .media-folders>.media-folder-actions{position:absolute;top:-68px;right:0;margin:0;z-index:4}
|
||||
.media-screen .media-folders>.media-folder-actions>nav{right:0;top:calc(100% + 4px)}
|
||||
.media-screen .media-folders>.media-folder-actions>summary{min-height:40px;padding:8px 12px}
|
||||
.media-screen .media-folders>.media-folder-actions>nav{right:0;top:calc(100% + 4px)}
|
||||
|
||||
/* Secondary/detail headers share one fixed chrome row on desktop. */
|
||||
@media(min-width:701px){
|
||||
/* Detail queries render the shell before the detail article exists. Hide the
|
||||
channel masthead during that state so loading and loaded headers occupy the
|
||||
same row. */
|
||||
body:has(.media-screen.media-detail-shell) .media-channel-entry .media-brand,
|
||||
body:has(.media-screen.media-detail-shell) .media-channel-entry a { display:none; }
|
||||
.media-screen.media-detail-shell>.media-heading {
|
||||
position:fixed;
|
||||
z-index:90;
|
||||
top:0;
|
||||
/* left:max(140px,calc((100vw - 1060px)/2 + 110px)); */
|
||||
/* right:max(24px,calc((100vw - 1060px)/2)); */
|
||||
height:62px;
|
||||
min-height:62px;
|
||||
margin:0;
|
||||
padding:0 8px;
|
||||
box-sizing:border-box;
|
||||
border:0;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:var(--media-background);
|
||||
box-shadow:none;
|
||||
}
|
||||
.media-screen.media-detail-shell>.media-heading .media-page-heading { min-width:0; display:flex; align-items:center; gap:4px; }
|
||||
.media-screen.media-detail-shell>.media-heading .media-page-heading h1 { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
}
|
||||
@media(max-width:700px){
|
||||
.media-screen.media-detail-shell>.media-heading {
|
||||
min-height:56px;
|
||||
height:56px;
|
||||
margin:0 -16px;
|
||||
padding:0 8px 0 4px;
|
||||
box-sizing:border-box;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.media-screen.media-detail-shell>.media-heading .media-page-heading {min-width:0;gap:4px}
|
||||
}
|
||||
|
||||
/* Explicit menu styling keeps links and buttons on the themed palette. */
|
||||
.media-screen .media-actions nav {
|
||||
min-width:196px;
|
||||
padding:7px;
|
||||
gap:2px;
|
||||
background:var(--media-paper,var(--surface));
|
||||
color:var(--text);
|
||||
border-color:var(--border);
|
||||
box-shadow:0 10px 28px #0003;
|
||||
}
|
||||
.media-screen .media-actions nav :is(a,button) {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
width:100%;
|
||||
min-height:40px;
|
||||
box-sizing:border-box;
|
||||
padding:9px 10px;
|
||||
border:0;
|
||||
border-radius:7px;
|
||||
background:transparent;
|
||||
color:var(--text);
|
||||
font:inherit;
|
||||
line-height:1.4;
|
||||
text-align:left;
|
||||
text-decoration:none;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.media-screen .media-actions nav :is(a,button):hover:not(:disabled),
|
||||
.media-screen .media-actions nav :is(a,button):focus-visible { background:var(--media-selection); color:var(--media-accent); }
|
||||
.media-screen .media-actions nav :is(a,button) svg {flex:none;stroke:currentColor}
|
||||
.media-screen .media-library-management-actions>summary {color:var(--text)}
|
||||
.media-screen .media-library-management-actions[open]>summary {background:var(--media-selection);color:var(--media-accent)}
|
||||
.media-modal:has(.media-match-confirm){width:min(560px,calc(100vw - 32px));max-width:560px;padding:0;border-radius:16px;overflow:hidden}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm{margin:0;padding:20px;border:0;border-radius:0;background:var(--media-paper)}
|
||||
.media-modal:has(.media-match-confirm) .media-match-confirm p{font-size:14px;line-height:1.75;margin:0 0 18px}
|
||||
@@ -866,3 +948,50 @@ body[data-media-theme] {--surface-muted:var(--media-theme-surface)}
|
||||
.media-screen .media-folder-actions>summary { width:44px; min-width:44px; height:44px; box-sizing:border-box; padding:10px; border:1px solid var(--border); border-radius:50%; background:var(--surface); cursor:pointer; display:inline-flex; align-items:center; justify-content:center; list-style:none; }
|
||||
.media-screen .media-folder-actions>summary::-webkit-details-marker { display:none; }
|
||||
.media-screen .media-folder-actions[open]>summary { border-color:var(--media-accent); background:var(--media-selection); }
|
||||
|
||||
/* Final legacy chrome: folder menus use an unframed icon, secondary routes hide
|
||||
the global channel rail, and every secondary title keeps one subtle divider. */
|
||||
.media-screen .media-folder-actions>summary,
|
||||
.media-screen .media-folder-actions[open]>summary {
|
||||
border:0!important;
|
||||
border-radius:0!important;
|
||||
background:transparent!important;
|
||||
box-shadow:none!important;
|
||||
}
|
||||
|
||||
/* Secondary routes own the page chrome. The channel rail belongs to catalogue
|
||||
landing pages and must not remain visible behind a secondary title row. */
|
||||
body:has(.media-screen.media-secondary-page) .media-channel-entry,
|
||||
body:has(.media-screen.media-manager-workspace) .media-channel-entry,
|
||||
body:has(.media-screen.media-utility-workspace) .media-channel-entry,
|
||||
body:has(.media-screen.media-playback-page) .media-channel-entry,
|
||||
body:has(.media-screen.media-chapters-page) .media-channel-entry,
|
||||
body:has(.media-screen.media-metadata-workspace) .media-channel-entry,
|
||||
body:has(.media-screen.media-detail-shell) .media-channel-entry,
|
||||
body:has(.media-screen>.media-detail-page) .media-channel-entry,
|
||||
body:has(.media-narrators:not([data-view=directory])) .media-channel-entry {
|
||||
display:none!important;
|
||||
}
|
||||
|
||||
.media-screen:is(.media-secondary-page,.media-manager-workspace,.media-playback-page,.media-chapters-page,.media-detail-shell)>.media-heading,
|
||||
.media-screen:has(>.media-detail-page)>.media-heading,
|
||||
.media-screen.media-metadata-workspace>.media-metadata-host>.media-heading,
|
||||
.media-utility-workspace .media-settings-heading,
|
||||
.media-narrators:not([data-view=directory])>.media-narrator-heading {
|
||||
border-bottom:1px solid var(--border)!important;
|
||||
box-shadow:none!important;
|
||||
}
|
||||
|
||||
@media(min-width:701px){
|
||||
.media-screen:is(.media-secondary-page,.media-manager-workspace,.media-playback-page,.media-chapters-page,.media-detail-shell)>.media-heading,
|
||||
.media-screen:has(>.media-detail-page)>.media-heading,
|
||||
.media-screen.media-metadata-workspace>.media-metadata-host>.media-heading,
|
||||
.media-utility-workspace .media-settings-heading,
|
||||
.media-narrators:not([data-view=directory])>.media-narrator-heading {
|
||||
height:68px;
|
||||
min-height:68px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Folder rows are left-aligned text actions; only the chevron stays at the far edge. */
|
||||
.media-screen .media-folder-row>button { justify-content:flex-start; text-align:left; }
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ActionIcon, Alert, Badge, Button, Card, Center, Checkbox, Group, Loader, Pagination, SegmentedControl, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Alert } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { BookOpen, Headphones, LibraryBig, MoreHorizontal, Search, Settings, SlidersVertical } from 'lucide-react';
|
||||
import type { Book, ContinueReadingItem } from '../../../api/types.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
@@ -10,6 +11,8 @@ import { useShelf } from '../queries/shelf.queries.ts';
|
||||
import { SHELF_SORTS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
|
||||
import { OpdsAccess } from '../../../ui/opds-access.tsx';
|
||||
import { SystemSettings } from '../../../ui/system-settings.tsx';
|
||||
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
const coverCache = new WeakMap<object, Map<string, string>>();
|
||||
function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string | null | undefined): string | null {
|
||||
@@ -30,13 +33,22 @@ function useCover(api: ReturnType<typeof useRuntime>['api'], coverUrl: string |
|
||||
return src;
|
||||
}
|
||||
|
||||
function BookCard({ book, showAuthor, showProgress }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean }) {
|
||||
function BookCard({ book, showAuthor, showProgress, onRemove }: { book: Book | ContinueReadingItem; showAuthor: boolean; showProgress: boolean; onRemove?: () => void }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const progress = 'percentage' in book ? book.percentage : undefined;
|
||||
return <Card className="book-card" withBorder padding="sm" component={Link} to={`/book/${encodeURIComponent(book.id)}`} style={{ textDecoration: 'none' }}>
|
||||
<Stack gap="xs"><div style={{ position: 'relative', aspectRatio: '2 / 3', overflow: 'hidden', borderRadius: 6, background: 'var(--mantine-color-gray-1)' }}>{src ? <img src={src} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <Center h="100%"><Text c="dimmed" ta="center" lineClamp={3}>{book.title || '无封面'}</Text></Center>}{showProgress && progress !== undefined && progress > 0 && <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 5, background: 'var(--mantine-color-gray-3)' }}><div style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`, height: '100%', background: 'var(--mantine-color-blue-6)' }} /></div>}</div><Text fw={600} lineClamp={2}>{book.title || '未命名'}</Text>{showAuthor && <Text size="sm" c="dimmed" lineClamp={1}>{book.author || '未知作者'}</Text>}</Stack>
|
||||
</Card>;
|
||||
const progress = 'percentage' in book ? book.percentage : runtime.offline?.progressFor(book.id)?.percentage;
|
||||
return <div className="book-card">
|
||||
<Link className="book-open" aria-label={`${book.title} ${book.author ?? ''}`.trim()} to={`/book/${encodeURIComponent(book.id)}`}>
|
||||
<div className="cover">
|
||||
{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}
|
||||
<span className="format-badge">{book.format || '书籍'}</span>
|
||||
{showProgress && progress !== undefined && progress > 0 && <div className="cover-progress"><span style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` }} /></div>}
|
||||
</div>
|
||||
<div className="title">{book.title || '未命名'}</div>
|
||||
{showAuthor && <div className="author">{book.author || '未知作者'}</div>}
|
||||
</Link>
|
||||
{onRemove && <button type="button" className="book-more" aria-label={`${book.title} 的操作`} title="从书架拿掉" onClick={onRemove}><MoreHorizontal size={17} aria-hidden="true" /></button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ShelfPage() {
|
||||
@@ -49,64 +61,51 @@ export function ShelfPage() {
|
||||
const [sort, setSort] = useState<typeof settings.shelfSort>((params.get('sort') as typeof settings.shelfSort) || settings.shelfSort);
|
||||
const [search, setSearch] = useState(params.get('q') || '');
|
||||
const [queryText, setQueryText] = useState(params.get('q') || '');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [opdsOpen, setOpdsOpen] = useState(false);
|
||||
const [systemSettingsOpen, setSystemSettingsOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<Book | ContinueReadingItem | null>(null);
|
||||
const query = useShelf(page, sort, queryText);
|
||||
const continueQuery = useQuery({ queryKey: ['continue-reading', runtime.api.baseUrl, runtime.api.currentSession()?.user.id], queryFn: () => runtime.api.continueReading(8), staleTime: 30_000 });
|
||||
const pages = query.data ? Math.max(1, Math.ceil(query.data.total / query.data.pageSize)) : 1;
|
||||
const cards = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
useEffect(() => { void runtime.updateSettings({ shelfSort: sort }); }, [runtime, sort]);
|
||||
const onSort = (value: string) => { const next = value as typeof sort; setSort(next); setParams({ ...(queryText ? { q: queryText } : {}), sort: next, page: '1' }); };
|
||||
const onSearch = (event: FormEvent) => { event.preventDefault(); setQueryText(search.trim()); setParams({ ...(search.trim() ? { q: search.trim() } : {}), sort, page: '1' }); };
|
||||
const clearSearch = () => { setSearch(''); setQueryText(''); setParams({ sort, page: '1' }); };
|
||||
const refresh = async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
runtime.api.refreshQueries();
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['shelf'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['continue-reading'] }),
|
||||
]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
const remove = async (book: Book) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'remove'); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); await queryClient.invalidateQueries({ queryKey: ['continue-reading'] }); };
|
||||
const removeSelected = async () => { if (!selected.length) return; await runtime.api.browseBatchShelf({ bookIds: selected }, 'remove'); setSelected([]); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); await queryClient.invalidateQueries({ queryKey: ['continue-reading'] }); };
|
||||
const cards = useMemo(() => query.data?.items ?? [], [query.data]);
|
||||
return <Stack className="shelf-screen" p="md" maw={1440} mx="auto">
|
||||
<Group className="shelf-head" justify="space-between" align="center">
|
||||
<Title className="shelf-title" order={1}>{queryText ? '搜索结果' : '书架'}</Title>
|
||||
<Group className="shelf-head-actions" gap="xs">
|
||||
<Button component={Link} to="/library" variant="light">书库</Button>
|
||||
<Button component={Link} to="/sources" variant="light">书源</Button>
|
||||
<Button component={Link} to="/media/video" variant="subtle">影音</Button>
|
||||
<Button variant="subtle" loading={refreshing} onClick={() => void refresh()}>刷新</Button>
|
||||
<Button variant="subtle" onClick={() => setSettingsOpen(true)}>设置</Button>
|
||||
{user?.role === 'admin' && <Button variant="subtle" onClick={() => setSystemSettingsOpen(true)}>系统设置</Button>}
|
||||
</Group>
|
||||
</Group>
|
||||
{selected.length > 0 && <Group><Badge>已选 {selected.length} 本</Badge><Button size="compact-sm" color="red" onClick={() => void removeSelected()}>批量移出书架</Button><Button size="compact-sm" variant="subtle" onClick={() => setSelected([])}>清除选择</Button></Group>}
|
||||
<Group className="shelf-search" align="end" wrap="wrap">
|
||||
<form onSubmit={onSearch} style={{ flex: '1 1 20rem' }}>
|
||||
<TextInput label="搜索书名、作者" type="search" value={search} onChange={event => setSearch(event.currentTarget.value)} placeholder="输入关键词后回车" rightSection={search ? <ActionIcon aria-label="清除搜索" variant="subtle" onClick={clearSearch}><span aria-hidden="true">×</span></ActionIcon> : null} rightSectionPointerEvents={search ? 'all' : 'none'} />
|
||||
const remove = async (book: Book | ContinueReadingItem) => { await runtime.api.browseBatchShelf({ bookIds: [book.id] }, 'remove'); await queryClient.invalidateQueries({ queryKey: ['shelf'] }); };
|
||||
const subtitle = queryText ? (query.data?.total ? `「${queryText}」匹配 ${query.data.total} 本` : `没有匹配「${queryText}」的书`) : query.data?.total ? (sort === 'recent' ? `最近阅读 · 共 ${query.data.total} 本` : `按${SHELF_SORTS.find(item => item.value === sort)?.label ?? ''}排列 · ${query.data.total} 本`) : '收藏好书,随时接着读';
|
||||
const densityLabel = settings.shelfDensity === 'compact' ? '紧凑' : settings.shelfDensity === 'comfortable' ? '宽松' : '适中';
|
||||
return <div id="shelf-scroll" className="shelf shelf-screen">
|
||||
<header className="shelf-head">
|
||||
<div className="shelf-head-text"><h1 className="shelf-title">{queryText ? '搜索结果' : '我的书架'}</h1><p className="shelf-subtitle muted">{subtitle}</p></div>
|
||||
<div className="shelf-head-actions">
|
||||
<Link className="icon-button" aria-label="影音" title="影音" to="/media/video"><Headphones className="icon" size="1em" strokeWidth={1.8} /></Link>
|
||||
{user?.role === 'admin' && <button className="icon-button" aria-label="系统设置" title="系统设置" onClick={() => setSystemSettingsOpen(true)}><Settings className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">系统设置</span></button>}
|
||||
<button className="icon-button" aria-label={`书架设置 · ${densityLabel}`} title={`书架设置 · ${densityLabel}`} onClick={() => setSettingsOpen(true)}><SlidersVertical className="icon" size="1em" strokeWidth={1.8} /><span className="media-visually-hidden">设置</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="shelf-discovery">
|
||||
<nav className="collection-links shelf-links" aria-label="发现书籍">
|
||||
<Link className="button collection-link" to="/library"><LibraryBig size={18} /><span>书库</span><span aria-hidden="true">›</span></Link>
|
||||
<Link className="button collection-link" to="/sources"><Search size={18} /><span>书源</span><span aria-hidden="true">›</span></Link>
|
||||
</nav>
|
||||
<form className="shelf-search" role="search" onSubmit={onSearch}>
|
||||
<Search className="search-glyph" size={18} aria-hidden="true" />
|
||||
<input type="search" aria-label="搜索书库" placeholder="搜索书名、作者、系列" value={search} onChange={event => setSearch(event.currentTarget.value)} />
|
||||
{search && <button type="button" className="search-clear" aria-label="清除搜索" onClick={clearSearch}>×</button>}
|
||||
</form>
|
||||
<SegmentedControl aria-label="书架排序" value={sort} onChange={onSort} data={SHELF_SORTS} />
|
||||
</Group>
|
||||
{continueQuery.data && continueQuery.data.length > 0 && <Stack gap="xs"><Group justify="space-between"><Title order={3}>继续阅读</Title><Badge variant="light">{continueQuery.data.length} 本</Badge></Group><SimpleGrid className="book-grid continue-grid" cols={{ base: 2, xs: 3, sm: 4, md: 8 }}>{continueQuery.data.map(item => <BookCard key={item.id} book={item} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} />)}</SimpleGrid></Stack>}
|
||||
{query.isPending && <Center py="xl"><Loader /></Center>}
|
||||
{query.error && <Alert color="red" title="书架加载失败">{query.error.message}</Alert>}
|
||||
{query.data && cards.length === 0 && <Alert>{queryText ? '没有匹配的书。' : '书架为空。可以从书库浏览并加入书架。'}</Alert>}
|
||||
{cards.length > 0 && <SimpleGrid className="book-grid" cols={{ base: 2, xs: 3, sm: 4, md: 6, lg: 8 }}>{cards.map(book => <Stack key={book.id} gap={4}><Group justify="space-between"><Checkbox aria-label={`选择${book.title}`} checked={selected.includes(book.id)} onChange={() => setSelected(s => s.includes(book.id) ? s.filter(x => x !== book.id) : [...s, book.id])} /><Button size="compact-xs" variant="subtle" color="red" onClick={() => void remove(book)}>移出书架</Button></Group><BookCard book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} /></Stack>)}</SimpleGrid>}
|
||||
{query.data && pages > 1 && <Pagination total={pages} value={page} onChange={value => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(value) })} />}
|
||||
</div>
|
||||
<section className="shelf-section" aria-label={queryText ? '搜索结果' : '全部书籍'}>
|
||||
{(!query.isPending && (cards.length > 0 || queryText)) && <div className="shelf-toolbar"><span className="shelf-count muted">{queryText ? `找到 ${query.data?.total ?? 0} 本` : `共 ${query.data?.total ?? 0} 本`}</span><div className="shelf-sort" role="group" aria-label="排序方式">{SHELF_SORTS.map(option => <button key={option.value} type="button" className="chip" aria-pressed={option.value === sort} onClick={() => onSort(option.value)}>{option.label}</button>)}</div></div>}
|
||||
{query.isPending && <FloatingNotice message="正在加载书架…" busy />}
|
||||
{query.error && <Alert color="red" title="书架加载失败">{query.error.message}</Alert>}
|
||||
{query.data && cards.length === 0 && <div className="empty-state collection-empty"><BookOpen className="empty-glyph" aria-hidden="true" /><p>{queryText ? '没有匹配的书' : '书架还没有书'}</p><p className="muted">{queryText ? '换个关键词,或者检查一下作者名的写法' : '从书库挑选喜欢的书,或通过书源搜索,加入书架后就能开始阅读。'}</p><div className="empty-actions">{queryText ? <button type="button" className="button" onClick={clearSearch}>清除搜索</button> : <><Link className="button primary" to="/sources"><Search size={16} />去搜书</Link><Link className="button" to="/library"><LibraryBig size={16} />打开书库</Link></>}</div></div>}
|
||||
{cards.length > 0 && <div className="book-grid" data-density={settings.shelfDensity} data-show-author={String(settings.shelfShowAuthor)} data-show-progress={String(settings.shelfShowProgress)}>{cards.map(book => <BookCard key={book.id} book={book} showAuthor={settings.shelfShowAuthor} showProgress={settings.shelfShowProgress} onRemove={() => setRemoveTarget(book)} />)}</div>}
|
||||
{query.data && pages > 1 && <nav className="shelf-pager" aria-label="翻页"><button type="button" disabled={page <= 1} onClick={() => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(page - 1) })}>上一页</button><span>{page} / {pages}</span><button type="button" disabled={page >= pages} onClick={() => setParams({ ...(queryText ? { q: queryText } : {}), sort, page: String(page + 1) })}>下一页</button></nav>}
|
||||
</section>
|
||||
<ShelfSettingsPanel open={settingsOpen} settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />
|
||||
{opdsOpen && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />}
|
||||
{systemSettingsOpen && <SystemSettings api={runtime.api} onClose={() => setSystemSettingsOpen(false)} />}
|
||||
</Stack>;
|
||||
{removeTarget && <DialogView dialog={{ kind: 'confirm', title: '移出书架', body: `确定要将《${removeTarget.title}》移出书架吗?`, resolve: (_answer: boolean) => undefined } satisfies Dialog} destructive="移出书架" onClose={answer => { const target = removeTarget; setRemoveTarget(null); if (answer === true) void remove(target); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
import { Alert, Button, Card, FileInput, Group, Loader, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, Package, Upload, X } from 'lucide-react';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { usePlugins, useSourceMutations } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function PluginManagementPage() {
|
||||
const navigate = useNavigate(); const admin = useAuthStore(state => state.verifiedUser?.role === 'admin'); const plugins = usePlugins(admin); const mutations = useSourceMutations();
|
||||
const [packageName, setPackageName] = useState(''); const [file, setFile] = useState<File | null>(null); const [trusted, setTrusted] = useState(false); const [error, setError] = useState('');
|
||||
if (!admin) return <Alert color="red">只有管理员可以管理插件。</Alert>; if (plugins.isPending) return <Loader />;
|
||||
const install = async () => { if (!trusted) { setError('请确认插件来源可信。'); return; } try { if (file) await mutations.uploadPlugin.mutateAsync(file); else if (packageName.trim()) await mutations.installPlugin.mutateAsync(packageName.trim()); else return; setPackageName(''); setFile(null); setTrusted(false); } catch (reason) { setError(reason instanceof Error ? reason.message : '插件安装失败'); } };
|
||||
return <Stack p="md" maw={950} mx="auto"><Group justify="space-between"><Title order={1}>插件管理</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group>{error && <Alert color="red">{error}</Alert>}<Card withBorder><Stack><Text fw={600}>安装插件</Text><TextInput label="npm 包名" placeholder="例如 reader-source-example" value={packageName} onChange={event => setPackageName(event.currentTarget.value)} /><FileInput label="或上传插件包" value={file} onChange={setFile} accept=".tgz,.zip,.js" /><label><input type="checkbox" checked={trusted} onChange={event => setTrusted(event.currentTarget.checked)} /> 我确认插件来源可信</label><Button onClick={() => void install()} loading={mutations.installPlugin.isPending || mutations.uploadPlugin.isPending}>安装并启用</Button></Stack></Card>{plugins.error && <Alert color="red">{plugins.error.message}</Alert>}{plugins.data?.map(plugin => <Card key={plugin.pluginId} withBorder><Group justify="space-between"><div><Text fw={600}>{plugin.name || plugin.pluginId}</Text><Text size="sm" c="dimmed">{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</Text>{plugin.error && <Text c="red">{plugin.error.message}</Text>}</div><Group>{!plugin.builtin && <><Button size="compact-sm" loading={mutations.enablePlugin.isPending} onClick={() => mutations.enablePlugin.mutate({ id: plugin.pluginId, enabled: !plugin.enabled })}>{plugin.enabled ? '停用' : '启用'}</Button><Button size="compact-sm" color="red" variant="subtle" loading={mutations.uninstallPlugin.isPending} onClick={() => mutations.uninstallPlugin.mutate(plugin.pluginId)}>卸载</Button></>}</Group></Group></Card>)}</Stack>;
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const plugins = usePlugins(admin);
|
||||
const mutations = useSourceMutations();
|
||||
const [packageName, setPackageName] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [trusted, setTrusted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const busy = mutations.installPlugin.isPending || mutations.uploadPlugin.isPending;
|
||||
if (!admin) return <div className="sources-screen plugin-management-screen"><FloatingNotice message="只有管理员可以管理插件。" error /></div>;
|
||||
const install = async () => {
|
||||
setError('');
|
||||
if (!trusted) { setError('请确认插件来源可信。'); return; }
|
||||
try {
|
||||
if (file) await mutations.uploadPlugin.mutateAsync(file);
|
||||
else if (packageName.trim()) await mutations.installPlugin.mutateAsync(packageName.trim());
|
||||
else { setError('请输入 npm 包名或选择插件包。'); return; }
|
||||
setPackageName(''); setFile(null); setTrusted(false); if (fileInput.current) fileInput.current.value = '';
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : '插件安装失败'); }
|
||||
};
|
||||
return <div className="sources-screen plugin-management-screen">
|
||||
<header className="sources-header"><Link className="icon-button" to="/sources" aria-label="返回" title="返回"><ArrowLeft size={20} /></Link><h1>插件管理</h1><span /></header>
|
||||
<main className="sources-body plugin-management-body">
|
||||
{plugins.isPending && <FloatingNotice message="正在加载插件列表…" busy />}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<section className="sources-card plugin-install-card">
|
||||
<div className="plugin-card-heading"><Package size={20} aria-hidden="true" /><div><h2>安装插件</h2><p className="muted">从 npm 包名或本地压缩包安装来源插件。</p></div></div>
|
||||
<p className="notice">插件以服务端权限运行。安装前请确认来源可信;npm 安装需要服务端能够访问 npm 仓库。</p>
|
||||
<form className="plugin-install-form" onSubmit={event => { event.preventDefault(); void install(); }}>
|
||||
<div className="plugin-install-grid">
|
||||
<label>npm 包名<input type="text" placeholder="例如 reader-source-example" value={packageName} onChange={event => setPackageName(event.currentTarget.value)} disabled={busy || Boolean(file)} /></label>
|
||||
<label>上传插件包<input ref={fileInput} type="file" accept=".tgz,application/gzip,application/x-gzip" onChange={event => setFile(event.currentTarget.files?.[0] ?? null)} disabled={busy || Boolean(packageName.trim())} /></label>
|
||||
</div>
|
||||
<label className="plugin-trust"><input type="checkbox" checked={trusted} onChange={event => setTrusted(event.currentTarget.checked)} />我信任这个插件的代码</label>
|
||||
<button className="button primary plugin-install-submit" type="submit" disabled={busy || !trusted || (!packageName.trim() && !file)}><Upload size={16} aria-hidden="true" />{busy ? '安装中…' : '安装并启用'}</button>
|
||||
</form>
|
||||
</section>
|
||||
{plugins.error && <div className="notice error" role="alert">{plugins.error.message}</div>}
|
||||
<section className="sources-card plugin-list-card"><div className="plugin-list-heading"><h2>已安装插件</h2><span className="source-count">{plugins.data?.length ?? 0}</span></div>
|
||||
{!plugins.isPending && !plugins.data?.length && <p className="muted">暂无已安装插件。</p>}
|
||||
<div className="plugin-list">{(plugins.data ?? []).map(plugin => <article className="sources-row plugin-row" key={plugin.pluginId}><div className="plugin-row-info"><strong>{plugin.name || plugin.pluginId}</strong><small>{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</small>{plugin.error && <small className="plugin-error">{plugin.error.message}</small>}</div>{!plugin.builtin && <div className="sources-actions plugin-row-actions"><button className="button" type="button" disabled={mutations.enablePlugin.isPending} onClick={() => mutations.enablePlugin.mutate({ id: plugin.pluginId, enabled: !plugin.enabled })}>{plugin.enabled ? <><X size={15} />停用</> : <><Check size={15} />启用</>}</button><button className="button danger" type="button" disabled={mutations.uninstallPlugin.isPending} onClick={() => mutations.uninstallPlugin.mutate(plugin.pluginId)}>卸载</button></div>}</article>)}</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Alert, Badge, Button, Card, Group, Loader, Modal, Select, SimpleGrid, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import type { SourceEntry, SourcePage } from '../../../api/sources.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useSources } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function mergePages(current: SourcePage | undefined, page: SourcePage): SourcePage {
|
||||
const items = new Map((current?.items ?? []).map(item => [item.ref, item]));
|
||||
@@ -25,19 +26,21 @@ export function SourceCatalogPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [cursor, setCursor] = useState<string | undefined>(params.get('cursor') ?? undefined);
|
||||
const sessionId = useRef<string | null>(null);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const filterQuery = useQuery({ queryKey: ['source-filters', api.baseUrl, sourceId], queryFn: () => api.sourceFilters(sourceId), enabled: !!source?.descriptor?.capabilities.includes('search.filters') });
|
||||
const detailQuery = useQuery({ queryKey: ['source-detail', api.baseUrl, sourceId, detail?.ref], queryFn: () => api.sourceDetail(sourceId, detail!.ref), enabled: !!detail });
|
||||
const acquire = useMutation({ mutationFn: ({ ref, option }: { ref: string; option?: string }) => api.acquireSource(sourceId, ref, option), onError: reason => setError(reason instanceof Error ? reason.message : '加入书架失败') });
|
||||
useEffect(() => { if (detail && detailQuery.data) setDetail({ ...detail, ...detailQuery.data }); }, [detailQuery.data]);
|
||||
useEffect(() => { const next = params.get('q') ?? ''; if (next !== query) setQuery(next); }, [params]);
|
||||
const acquireEntry = (entry: SourceEntry, option?: string) => acquire.mutate(option === undefined ? { ref: entry.ref } : { ref: entry.ref, option }, { onSuccess: result => { if (result.kind === 'action-required') setError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) navigate(`/book/${encodeURIComponent(result.publicationId)}`); } });
|
||||
|
||||
const search = async (append = false) => {
|
||||
const term = query.trim(); if (!term || !source) return;
|
||||
setBusy(true); setError(''); setParams(previous => { const next = new URLSearchParams(previous); next.set('q', term); if (!append) next.delete('cursor'); return next; });
|
||||
setBusy(true); setError(''); if (!append) { sessionId.current = crypto.randomUUID(); setPage(undefined); setCursor(undefined); } setParams(previous => { const next = new URLSearchParams(previous); next.set('q', term); if (!append) next.delete('cursor'); return next; });
|
||||
try {
|
||||
let merged = append ? page : undefined;
|
||||
const sessionId = crypto.randomUUID();
|
||||
for await (const chunk of api.searchSource(source.id, { query: term, sessionId, ...(append && cursor ? { cursor } : {}), filters, resultLimit: 10000 })) {
|
||||
for await (const chunk of api.searchSource(source.id, { query: term, sessionId: sessionId.current ?? crypto.randomUUID(), ...(append && cursor ? { cursor } : {}), filters, resultLimit: 10000 })) {
|
||||
merged = mergePages(merged, chunk); setPage(merged); setCursor(chunk.nextCursor); if (chunk.limitReached) break;
|
||||
}
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : '搜索失败'); }
|
||||
@@ -45,26 +48,39 @@ export function SourceCatalogPage() {
|
||||
};
|
||||
const browse = async (ref?: string) => {
|
||||
if (!source) return; setBusy(true); setError('');
|
||||
try { const next = await api.sourceCatalog(source.id, ref ? { ref } : {}); setPage(next); setCursor(next.nextCursor); setParams(previous => { const value = new URLSearchParams(previous); if (ref) value.set('ref', ref); else value.delete('ref'); return value; }); }
|
||||
try { sessionId.current = null; const next = await api.sourceCatalog(source.id, ref ? { ref } : {}); setPage(next); setCursor(next.nextCursor); setParams(previous => { const value = new URLSearchParams(previous); if (ref) value.set('ref', ref); else value.delete('ref'); return value; }); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : '目录加载失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
if (sources.isPending) return <Loader />;
|
||||
if (!source) return <Stack p="md" maw={600} mx="auto"><Title order={1}>选择书源</Title><Text c="dimmed">选择一个已启用的书源开始搜索或浏览。</Text><Select label="书源" placeholder="选择来源" data={sources.data?.filter(item => item.enabled && item.descriptor).map(item => ({ value: item.id, label: item.name })) ?? []} onChange={value => value && navigate(`/sources/${encodeURIComponent(value)}/browse`)} /><Button variant="subtle" onClick={() => navigate('/sources')}>返回书源管理</Button></Stack>;
|
||||
if (sources.isPending) return <div className="sources-screen"><FloatingNotice message="正在加载书源…" busy /><main className="sources-body" /></div>;
|
||||
if (!source) return <div className="sources-screen source-catalog-screen"><header className="sources-header"><Button className="button icon-button" variant="subtle" onClick={() => navigate('/sources')} aria-label="返回">←</Button><h1>选择书源</h1></header><main className="sources-body"><section className="sources-card source-picker"><h2>选择来源</h2><p className="muted">选择一个已启用的书源开始搜索或浏览。</p><Select label="书源" placeholder="选择来源" data={sources.data?.filter(item => item.enabled && item.descriptor).map(item => ({ value: item.id, label: item.name })) ?? []} onChange={value => value && navigate(`/sources/${encodeURIComponent(value)}/browse`)} /></section></main></div>;
|
||||
const supportsSearch = source.descriptor?.capabilities.includes('search');
|
||||
const supportsBrowse = source.descriptor?.capabilities.includes('browse');
|
||||
return <Stack p="md" maw={1200} mx="auto">
|
||||
<Group justify="space-between"><div><Title order={1}>{source.name}</Title><Text c="dimmed">{source.descriptor?.label ?? '书源目录'}</Text></div><Button variant="subtle" onClick={() => navigate('/sources')}>返回书源</Button></Group>
|
||||
return <div className="sources-screen source-catalog-screen">
|
||||
<header className="sources-header"><Button className="button icon-button" variant="subtle" onClick={() => navigate('/sources')} aria-label="返回">←</Button><div><h1>{source.name}</h1><p className="muted">{source.descriptor?.label ?? '书源目录'}</p></div></header>
|
||||
<main className="sources-body">
|
||||
{error && <Alert color="red" withCloseButton onClose={() => setError('')}>{error}</Alert>}
|
||||
{supportsSearch && <Card withBorder><form onSubmit={event => { event.preventDefault(); void search(false); }}><Stack><TextInput label="搜索书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /><Group>{filterQuery.data?.map(field => field.type === 'select' ? <Select key={field.key} label={field.label} value={filters[field.key] ?? ''} data={field.options?.map(option => ({ value: option.value, label: option.label })) ?? []} onChange={value => setFilters(previous => ({ ...previous, [field.key]: value ?? '' }))} /> : null)}<Button type="submit" loading={busy}>搜索</Button>{page?.nextCursor && <Button type="button" variant="light" loading={busy} onClick={() => void search(true)}>继续搜索</Button>}</Group></Stack></form></Card>}
|
||||
{supportsBrowse && <Button variant="light" loading={busy} onClick={() => void browse()}>浏览目录首页</Button>}
|
||||
{supportsSearch && <section className="sources-card source-catalog"><form className="sources-search" onSubmit={event => { event.preventDefault(); void search(false); }}><label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" value={query} onChange={event => setQuery(event.currentTarget.value)} required /></label>{filterQuery.data?.map(field => field.type === 'select' ? <label key={field.key}>{field.label}<Select value={filters[field.key] ?? ''} data={field.options?.map(option => ({ value: option.value, label: option.label })) ?? []} onChange={value => setFilters(previous => ({ ...previous, [field.key]: value ?? '' }))} /></label> : null)}<Button type="submit" loading={busy}>搜索</Button>{page?.nextCursor && <Button type="button" variant="light" loading={busy} onClick={() => void search(true)}>继续搜索</Button>}</form></section>}
|
||||
<div className="source-catalog-actions">{supportsBrowse && <Button className="button" variant="light" loading={busy} onClick={() => void browse()}>浏览目录首页</Button>}</div>
|
||||
{page?.batch && <Text size="sm" c="dimmed">已检查 {page.batch.completed} / {page.batch.total} 个来源</Text>}
|
||||
{page?.navigation && <Group>{page.navigation.map(item => <Button key={item.ref} variant="subtle" onClick={() => void browse(item.ref)}>{item.title}</Button>)}</Group>}
|
||||
{page?.navigation && <Group className="source-navigation">{page.navigation.map(item => <Button className="button" key={item.ref} variant="subtle" onClick={() => void browse(item.ref)}>{item.title}</Button>)}</Group>}
|
||||
{page?.errors?.map(item => <Alert key={`${item.source}-${item.code}`} color="yellow">{item.source}: {item.message}</Alert>)}
|
||||
{!page && !busy && <Alert>输入关键词搜索,或浏览书源目录。</Alert>}
|
||||
{busy && !page && <Loader />}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>{page?.items.map(item => <Card key={item.ref} withBorder shadow="sm"><Stack><Group justify="space-between"><Text fw={600}>{item.title}</Text>{item.latestChapter && <Badge>{item.latestChapter}</Badge>}</Group>{item.authors?.length ? <Text size="sm" c="dimmed">{item.authors.join('、')}</Text> : null}{item.description && <Text size="sm" lineClamp={3}>{item.description}</Text>}<Button onClick={() => setDetail(item)}>查看详情</Button></Stack></Card>)}</SimpleGrid>
|
||||
{busy && !page && <FloatingNotice message="正在搜索…" busy />}
|
||||
<section className="sources-card"><div className="source-results">{page?.items.map(item => { const option = item.options?.[0]; return <article className="sources-row catalog-book" key={item.ref}><div><strong>{item.title}</strong>{item.latestChapter && <small>{item.latestChapter}</small>}{item.authors?.length ? <small>{item.authors.join('、')}</small> : null}{item.description && <p className="source-description">{item.description}</p>}</div><div className="sources-actions"><Button className="button" onClick={() => setDetail(item)}>详情</Button><Button className="button primary" disabled={option?.available === false} loading={acquire.isPending} onClick={() => acquireEntry(item, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</div></section>
|
||||
{page && !busy && page.items.length === 0 && <Alert>没有找到匹配书籍。</Alert>}
|
||||
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}><Stack>{detailQuery.isPending && <Loader />}{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}{detail && <><Text>{detail.description || '暂无简介'}</Text><Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquire.mutate({ ref: detail.ref, option: option.id }, { onSuccess: result => { if (result.kind === 'action-required') setError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) navigate(`/book/${encodeURIComponent(result.publicationId)}`); } })}>{option.label}</Button>)}{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquire.mutate({ ref: detail.ref }, { onSuccess: result => { if (result.kind === 'action-required') setError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) navigate(`/book/${encodeURIComponent(result.publicationId)}`); } })}>加入书架</Button>}</>}</Stack></Modal>
|
||||
</Stack>;
|
||||
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}>
|
||||
<Stack>
|
||||
{detailQuery.isPending && <FloatingNotice message="正在读取书籍详情…" busy />}
|
||||
{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}
|
||||
{detail && <>
|
||||
<Text>{detail.description || '暂无简介'}</Text>
|
||||
<Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>
|
||||
{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquireEntry(detail, option.id)}>{option.label}</Button>)}
|
||||
{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquireEntry(detail)}>加入书架</Button>}
|
||||
</>}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Alert, Button, Card, Group, Loader, PasswordInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { Alert, Button, Card, Group, PasswordInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useSources, useSourceMutations } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function SourceCredentialsPage() {
|
||||
const { api } = useRuntime(); const navigate = useNavigate(); const { sourceId = '' } = useParams();
|
||||
const sources = useSources(); const source = sources.data?.find(item => item.id === sourceId); const mutations = useSourceMutations();
|
||||
const status = useQuery({ queryKey: ['source-credentials', api.baseUrl, sourceId], queryFn: () => api.credentialStatus(sourceId), enabled: !!source });
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
if (sources.isPending) return <Loader />; if (!source) return <Alert color="red">来源不存在。</Alert>;
|
||||
if (sources.isPending) return <FloatingNotice message="正在加载书源…" busy />; if (!source) return <Alert color="red">来源不存在。</Alert>;
|
||||
const save = async (event: React.FormEvent) => { event.preventDefault(); for (const field of source.descriptor?.credentialKeys ?? []) if (field.key in values) { const value = values[field.key] ?? ''; if (value) await mutations.credential.mutateAsync({ id: source.id, key: field.key, value }); else await api.deleteCredential(source.id, field.key); } navigate('/sources'); };
|
||||
return <Stack p="md" maw={700} mx="auto"><Group justify="space-between"><Title order={1}>{source.name} · 凭据</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group><Card withBorder><form onSubmit={save}><Stack>{status.isPending && <Loader />}{status.data && <><Text>状态:{{ unknown: '尚未验证访问', reachable: '最近访问成功', 'auth-required': '需要登录或凭据已过期', 'verification-required': '需要人工验证' }[status.data.state]}</Text>{!status.data.available && <Alert color="yellow">来源当前不可用,请联系管理员检查插件。</Alert>}</>}{source.descriptor?.credentialKeys?.map(field => <PasswordInput key={field.key} label={`${field.label} · ${status.data?.fields.find(item => item.key === field.key)?.configured ? '已配置' : '未配置'}`} value={values[field.key] ?? ''} onChange={event => setValues(previous => ({ ...previous, [field.key]: event.currentTarget.value }))} description="留空并保存可删除此凭据" autoComplete="off" />)}<Button type="submit" loading={mutations.credential.isPending}>保存凭据</Button></Stack></form></Card></Stack>;
|
||||
return <Stack p="md" maw={700} mx="auto"><Group justify="space-between"><Title order={1}>{source.name} · 凭据</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group><Card withBorder><form onSubmit={save}><Stack>{status.isPending && <FloatingNotice message="正在读取凭据状态…" busy />}{status.data && <><Text>状态:{{ unknown: '尚未验证访问', reachable: '最近访问成功', 'auth-required': '需要登录或凭据已过期', 'verification-required': '需要人工验证' }[status.data.state]}</Text>{!status.data.available && <Alert color="yellow">来源当前不可用,请联系管理员检查插件。</Alert>}</>}{source.descriptor?.credentialKeys?.map(field => <PasswordInput key={field.key} label={`${field.label} · ${status.data?.fields.find(item => item.key === field.key)?.configured ? '已配置' : '未配置'}`} value={values[field.key] ?? ''} onChange={event => setValues(previous => ({ ...previous, [field.key]: event.currentTarget.value }))} description="留空并保存可删除此凭据" autoComplete="off" />)}<Button type="submit" loading={mutations.credential.isPending}>保存凭据</Button></Stack></form></Card></Stack>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Alert, Button, Card, Group, Loader, Select, Stack, Switch, Text, TextInput, Textarea, Title } from '@mantine/core';
|
||||
import { Alert, Button, Card, Group, Select, Stack, Switch, Text, TextInput, Textarea, Title } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { SourceType } from '../../../api/sources.ts';
|
||||
import { useSources, useSourceMutations, useSourceTypes } from '../hooks/sourceQueries.ts';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
|
||||
export function SourceEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,7 +16,7 @@ export function SourceEditorPage() {
|
||||
const [raw, setRaw] = useState(JSON.stringify(source?.config ?? {}, null, 2));
|
||||
useEffect(() => { if (!source) return; setTypeKey(`${source.pluginId}/${source.sourceType}`); setName(source.name); setConfig(source.config ?? {}); setRaw(JSON.stringify(source.config ?? {}, null, 2)); }, [source]);
|
||||
const type: SourceType | undefined = types.data?.find(item => `${item.pluginId}/${item.id}` === typeKey) ?? (source ? types.data?.find(item => item.pluginId === source.pluginId && item.id === source.sourceType) : undefined);
|
||||
if (sources.isPending || types.isPending) return <Loader />;
|
||||
if (sources.isPending || types.isPending) return <FloatingNotice message="正在加载书源配置…" busy />;
|
||||
if (sourceId && !source) return <Alert color="red">来源不存在。</Alert>;
|
||||
const save = async (event: React.FormEvent) => { event.preventDefault(); let value = config; try { if (!type?.configSchema?.properties) value = JSON.parse(raw) as Record<string, unknown>; } catch { return; } await mutations.save.mutateAsync({ id: sourceId ?? null, input: { name: name.trim(), config: value, ...(sourceId ? {} : { pluginId: type?.pluginId, sourceType: type?.id }) } }); navigate('/sources'); };
|
||||
return <Stack p="md" maw={800} mx="auto"><Group justify="space-between"><Title order={1}>{sourceId ? '编辑书源' : '添加书源'}</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group><Card withBorder><form onSubmit={save}><Stack><Select label="来源类型" required disabled={!!sourceId} value={typeKey} data={types.data?.map(item => ({ value: `${item.pluginId}/${item.id}`, label: `${item.label} (${item.pluginId})` })) ?? []} onChange={value => { setTypeKey(value ?? ''); setConfig({}); setRaw('{}'); }} /><TextInput label="名称" required value={name} onChange={event => setName(event.currentTarget.value)} />{type?.configSchema?.properties ? Object.entries(type.configSchema.properties).map(([key, field]) => field.type === 'boolean' ? <Switch key={key} label={field.title ?? key} checked={config[key] === true} onChange={event => setConfig(previous => ({ ...previous, [key]: event.currentTarget.checked }))} /> : field.enum ? <Select key={key} label={field.title ?? key} value={String(config[key] ?? field.default ?? '')} data={field.enum.map((value, index) => ({ value, label: field.enumNames?.[index] ?? value }))} onChange={value => setConfig(previous => ({ ...previous, [key]: value ?? '' }))} /> : <TextInput key={key} label={field.title ?? key} type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'} value={String(config[key] ?? field.default ?? '')} onChange={event => setConfig(previous => ({ ...previous, [key]: field.type === 'number' || field.type === 'integer' ? Number(event.currentTarget.value) : event.currentTarget.value }))} />) : <Textarea label="配置 JSON" value={raw} onChange={event => setRaw(event.currentTarget.value)} minRows={8} />}{source?.descriptor?.credentialKeys?.length ? <Text size="sm" c="dimmed">凭据不会保存到配置中,请保存后单独设置。</Text> : null}<Group><Button type="submit" loading={mutations.save.isPending}>保存</Button><Button type="button" variant="light" onClick={() => navigate('/sources')}>取消</Button>{sourceId && <Button type="button" color="red" variant="subtle" loading={mutations.remove.isPending} onClick={async () => { await mutations.remove.mutateAsync(sourceId); navigate('/sources'); }}>删除来源</Button>}</Group></Stack></form></Card></Stack>;
|
||||
|
||||
@@ -1,40 +1,161 @@
|
||||
import { Alert, Button, Card, Checkbox, Group, Loader, NumberInput, PasswordInput, Select, Stack, Tabs, Text, Textarea, TextInput, Title } from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { ExtensionContent, ExtensionField, ExtensionForm } from '../../../api/sources.ts';
|
||||
import type { ExtensionContent, ExtensionField, ExtensionForm, ExtensionPage } from '../../../api/sources.ts';
|
||||
import { ApiError } from '../../../api/errors.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { FloatingConfirm } from '../../../ui/floating-confirm.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { Button, IconButton } from '../../../ui/toolkit.tsx';
|
||||
|
||||
function Field({ field, value, onChange, disabled }: { field: ExtensionField; value: unknown; onChange: (value: unknown) => void; disabled?: boolean }) {
|
||||
if (field.type === 'boolean') return <Checkbox label={field.label} checked={value === true} disabled={disabled} onChange={event => onChange(event.currentTarget.checked)} />;
|
||||
const required = field.required ? { required: true } : {};
|
||||
const placeholder = field.placeholder ? { placeholder: field.placeholder } : {};
|
||||
if (field.type === 'select') return <Select label={field.label} {...required} value={String(value ?? '')} data={field.options?.map(option => ({ value: option.value, label: option.label })) ?? []} {...(disabled ? { disabled: true } : {})} onChange={next => onChange(next ?? '')} />;
|
||||
if (field.type === 'textarea') return <Textarea label={field.label} {...required} value={String(value ?? '')} {...(disabled ? { disabled: true } : {})} onChange={event => onChange(event.currentTarget.value)} />;
|
||||
if (field.type === 'number') return <NumberInput label={field.label} {...required} {...(field.min !== undefined ? { min: field.min } : {})} {...(field.max !== undefined ? { max: field.max } : {})} value={typeof value === 'number' ? value : ''} {...(disabled ? { disabled: true } : {})} onChange={next => onChange(typeof next === 'number' ? next : Number(next))} />;
|
||||
if (field.type === 'password') return <PasswordInput label={field.label} {...required} {...placeholder} value={String(value ?? '')} {...(disabled ? { disabled: true } : {})} onChange={event => onChange(event.currentTarget.value)} />;
|
||||
return <TextInput label={field.label} {...required} {...placeholder} value={String(value ?? '')} {...(disabled ? { disabled: true } : {})} onChange={event => onChange(event.currentTarget.value)} />;
|
||||
type FormValues = Record<string, unknown>;
|
||||
|
||||
function initialValues(form: ExtensionForm): FormValues {
|
||||
const values: FormValues = { ...(form.values ?? {}) };
|
||||
for (const field of form.fields) {
|
||||
if (values[field.key] === undefined) values[field.key] = field.value ?? (field.type === 'boolean' ? false : '');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function FormBlock({ form, submit }: { form: ExtensionForm; submit: (id: string, values: Record<string, unknown>) => void }) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => ({ ...(form.values ?? {}), ...Object.fromEntries(form.fields.map(field => [field.key, field.value ?? (field.type === 'boolean' ? false : '')])) }));
|
||||
return <form onSubmit={event => { event.preventDefault(); submit(form.id, values); }}><Stack>{form.title && <Text fw={600}>{form.title}</Text>}{form.fields.map(field => <Field key={field.key} field={field} value={values[field.key]} onChange={value => setValues(previous => ({ ...previous, [field.key]: value }))} />)}<Button type="submit">{form.submit}</Button></Stack></form>;
|
||||
function safeExternalUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function Content({ content, submit }: { content: ExtensionContent; submit: (id: string, values: Record<string, unknown>) => void }) {
|
||||
return <Stack>{content.links?.map(link => <Button key={link.url} component="a" href={link.url} target="_blank" rel="noopener noreferrer" variant="light">{link.title}</Button>)}{content.forms.map(form => <Card key={form.id} withBorder><FormBlock form={form} submit={submit} /></Card>)}{content.sections?.map(section => <Card key={section.title} withBorder><Title order={3}>{section.title}</Title>{section.items.length === 0 && <Text c="dimmed">{section.emptyText ?? '暂无内容'}</Text>}{section.items.map(item => <Card key={item.key ?? item.title} withBorder mt="sm"><Text fw={600}>{item.title}</Text>{item.description && <Text size="sm" c="dimmed">{item.description}</Text>}{item.forms?.map(form => <FormBlock key={form.id} form={form} submit={submit} />)}</Card>)}</Card>)}{content.outputs?.map(output => <Card key={output.title} withBorder><Text fw={600}>{output.title}</Text><pre style={{ whiteSpace: 'pre-wrap' }}>{output.text}</pre></Card>)}</Stack>;
|
||||
function fileToValue(file: File): Promise<{ name: string; type: string; data: string }> {
|
||||
return file.arrayBuffer().then(buffer => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
return { name: file.name, type: file.type, data: btoa(binary) };
|
||||
});
|
||||
}
|
||||
|
||||
function FieldControl({ field, value, disabled, onChange, onSelectAction }: {
|
||||
field: ExtensionField; value: unknown; disabled: boolean; onChange(value: unknown): void;
|
||||
onSelectAction?(field: ExtensionField, value: string): void;
|
||||
}) {
|
||||
const common = { 'aria-label': field.label, required: field.required, disabled };
|
||||
if (field.type === 'textarea') return <textarea {...common} placeholder={field.placeholder} value={String(value ?? '')} onChange={event => onChange(event.currentTarget.value)} />;
|
||||
if (field.type === 'file') return <input {...common} type="file" onChange={event => { const file = event.currentTarget.files?.[0]; if (file) void fileToValue(file).then(onChange); }} />;
|
||||
if (field.type === 'boolean') return <input {...common} type="checkbox" checked={value === true} onChange={event => onChange(event.currentTarget.checked)} />;
|
||||
if (field.type === 'select') return <select {...common} value={String(value ?? '')} onChange={event => { const next = event.currentTarget.value; onChange(next); if (field.changeAction) onSelectAction?.(field, next); }}>
|
||||
{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>;
|
||||
return <input {...common} type={field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text'} autoComplete={field.type === 'password' ? 'off' : undefined} placeholder={field.placeholder} min={field.min} max={field.max} value={value === undefined || value === null ? '' : String(value)} onChange={event => {
|
||||
const raw = event.currentTarget.value;
|
||||
onChange(field.type === 'number' ? (raw === '' ? '' : Number(raw)) : raw);
|
||||
}} />;
|
||||
}
|
||||
|
||||
function FormView({ form, formKey, values, busy, selected, onChange, onSubmit, onSelectAction }: {
|
||||
form: ExtensionForm; formKey: string; values: FormValues; busy: boolean; selected: Set<string>;
|
||||
onChange(key: string, value: unknown): void;
|
||||
onSubmit(form: ExtensionForm, values: FormValues, formKey: string): void;
|
||||
onSelectAction(field: ExtensionField, value: string, form: ExtensionForm, formKey: string): void;
|
||||
}) {
|
||||
return <form className={form.layout === 'inline' ? 'extension-form-inline' : undefined} onSubmit={event => { event.preventDefault(); onSubmit(form, { ...values, ...(form.id === 'batch' ? { ids: [...selected] } : {}) }, formKey); }}>
|
||||
{form.title && <h3>{form.title}</h3>}
|
||||
{form.fields.map(field => <label key={field.key} data-field-type={field.type}>
|
||||
{field.label}
|
||||
<FieldControl field={field} value={values[field.key]} disabled={busy} onChange={value => onChange(field.key, value)} onSelectAction={(changed, value) => onSelectAction(changed, value, form, formKey)} />
|
||||
</label>)}
|
||||
<Button type="submit" disabled={busy}>{form.submit}</Button>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function ContentView({ content, scope, values, busy, selected, expanded, onChange, onSubmit, onSelectAction, onToggle, onToggleSelected }: {
|
||||
content: ExtensionContent; scope: string; values: Record<string, FormValues>; busy: boolean; selected: Set<string>; expanded: Set<string>;
|
||||
onChange(scope: string, key: string, value: unknown): void;
|
||||
onSubmit(form: ExtensionForm, values: FormValues, formKey: string): void;
|
||||
onSelectAction(field: ExtensionField, value: string, form: ExtensionForm, formKey: string): void;
|
||||
onToggle(key: string): void; onToggleSelected(key: string): void;
|
||||
}) {
|
||||
const editor = <>
|
||||
{content.links?.filter(link => safeExternalUrl(link.url)).map(link => <a className="button" key={link.url} href={link.url} target="_blank" rel="noopener noreferrer">{link.title}</a>)}
|
||||
{content.forms.map(form => { const key = `${scope}:form:${form.id}`; return <section className="sources-card" key={key}><FormView form={form} formKey={key} values={values[key] ?? initialValues(form)} busy={busy} selected={selected} onChange={(field, value) => onChange(key, field, value)} onSubmit={onSubmit} onSelectAction={onSelectAction} /></section>; })}
|
||||
</>;
|
||||
const output = <>
|
||||
{content.sections?.map((section, sectionIndex) => <section className="extension-section" key={`${scope}:section:${sectionIndex}`}>
|
||||
<h2>{section.title}</h2>
|
||||
{!section.items.length && section.emptyText && <p className="extension-empty">{section.emptyText}</p>}
|
||||
{section.items.map((item, itemIndex) => { const key = `${scope}:item:${sectionIndex}:${itemIndex}`; const open = expanded.has(key); return <article className="sources-card extension-item" key={key}>
|
||||
<div className="extension-item-heading"><div>
|
||||
{item.selectable && item.key && <input type="checkbox" aria-label={`选择 ${item.title}`} checked={selected.has(item.key)} onChange={() => onToggleSelected(item.key!)} />}
|
||||
<h3>{item.title}</h3>{item.description && <p className="source-description">{item.description}</p>}
|
||||
</div>{item.collapsible && <button className="button" type="button" aria-expanded={open} aria-controls={`extension-item-${key}`} onClick={() => onToggle(key)}>{open ? '收起' : '管理'}</button>}</div>
|
||||
<div id={`extension-item-${key}`} hidden={item.collapsible && !open} className="extension-actions">{item.forms?.map(form => { const formKey = `${key}:form:${form.id}`; return <FormView key={formKey} form={form} formKey={formKey} values={values[formKey] ?? initialValues(form)} busy={busy} selected={selected} onChange={(field, value) => onChange(formKey, field, value)} onSubmit={onSubmit} onSelectAction={onSelectAction} />; })}</div>
|
||||
</article>; })}
|
||||
</section>)}
|
||||
{content.outputs?.map(output => <details className="sources-card extension-output" open key={`${scope}:output:${output.title}`}><summary>{output.title}</summary><pre tabIndex={0} aria-label={output.title} data-format={output.format}>{output.text}</pre></details>)}
|
||||
</>;
|
||||
return content.layout === 'workbench' ? <div className="extension-workbench"><div className="extension-workbench-editor">{editor}</div><div className="extension-workbench-output">{output}</div></div> : <>{editor}{output}</>;
|
||||
}
|
||||
|
||||
export function SourceExtensionPage() {
|
||||
const { api } = useRuntime(); const navigate = useNavigate(); const { sourceId, pluginId, pageId = '' } = useParams(); const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const queryClient = useQueryClient();
|
||||
const owner = sourceId ? `source:${sourceId}` : `plugin:${pluginId}`;
|
||||
const pageQuery = useQuery({ queryKey: ['source-extension', api.baseUrl, owner, pageId], queryFn: () => sourceId ? api.sourcePage(sourceId, pageId) : api.pluginPage(pluginId!, pageId), enabled: !!pageId && (!!sourceId || admin) });
|
||||
const action = useMutation({ mutationFn: ({ id, values }: { id: string; values: Record<string, unknown> }) => sourceId ? api.sourcePage(sourceId, pageId, id, values) : api.pluginPage(pluginId!, pageId, id, values), onSuccess: data => { queryClient.setQueryData(['source-extension', api.baseUrl, owner, pageId], data); } });
|
||||
const page = pageQuery.data;
|
||||
if (!sourceId && !admin) return <Alert color="red">只有管理员可以访问插件配置。</Alert>;
|
||||
if (pageQuery.isPending) return <Loader />; if (pageQuery.error) return <Alert color="red">{pageQuery.error.message}</Alert>; if (!page) return null;
|
||||
const tabs = page.tabs ?? []; const selected = page.activeTab ?? tabs[0]?.id;
|
||||
return <Stack p="md" maw={1100} mx="auto"><Group justify="space-between"><Title order={1}>{page.title}</Title><Button variant="subtle" onClick={() => navigate('/sources')}>返回</Button></Group>{page.description && <Text c="dimmed">{page.description}</Text>}<Tabs defaultValue={selected}><Tabs.List>{tabs.map(tab => <Tabs.Tab key={tab.id} value={tab.id}>{tab.title}</Tabs.Tab>)}</Tabs.List><Tabs.Panel value={selected ?? ''} pt="md"><Content content={page} submit={(id, values) => action.mutate({ id, values })} /></Tabs.Panel>{tabs.map(tab => <Tabs.Panel key={tab.id} value={tab.id} pt="md"><Content content={tab} submit={(id, values) => action.mutate({ id, values })} /></Tabs.Panel>)}</Tabs>{action.error && <Alert color="red">{action.error instanceof Error ? action.error.message : '操作失败'}</Alert>}</Stack>;
|
||||
const runtime = useRuntime(); const { api } = runtime; const navigate = useNavigate();
|
||||
const { sourceId, pluginId, pageId = '' } = useParams(); const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const queryClient = useQueryClient(); const owner = sourceId ? `source:${sourceId}` : `plugin:${pluginId}`; const queryKey = ['source-extension', api.baseUrl, owner, pageId];
|
||||
const pageQuery = useQuery({ queryKey, queryFn: () => sourceId ? api.sourcePage(sourceId, pageId) : api.pluginPage(pluginId!, pageId), enabled: Boolean(pageId && (sourceId || admin)) });
|
||||
const action = useMutation({ mutationFn: ({ id, values }: { id?: string; values?: FormValues }) => sourceId ? api.sourcePage(sourceId, pageId, id, values) : api.pluginPage(pluginId!, pageId, id, values) });
|
||||
const page = pageQuery.data as ExtensionPage | undefined; const [activeTab, setActiveTab] = useState('');
|
||||
const [values, setValues] = useState<Record<string, FormValues>>({}); const [selected, setSelected] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState(''); const [noticeTab, setNoticeTab] = useState(''); const [confirmation, setConfirmation] = useState<{ form: ExtensionForm; values: FormValues; key: string } | null>(null); const busy = action.isPending;
|
||||
const tabs = page?.tabs ?? []; const selectedTab = useMemo(() => tabs.find(tab => tab.id === activeTab), [tabs, activeTab]);
|
||||
useEffect(() => { setActiveTab(''); setValues({}); setSelected(new Set()); setExpanded(new Set()); setError(''); setNoticeTab(''); setConfirmation(null); }, [owner, pageId]);
|
||||
useEffect(() => { if (!page) return; setActiveTab(current => page.activeTab && !current ? page.activeTab : (current && tabs.some(tab => tab.id === current) ? current : tabs[0]?.id ?? '')); setNoticeTab(current => current || page.activeTab || tabs[0]?.id || ''); }, [page, tabs]);
|
||||
const updateValue = (key: string, field: string, value: unknown) => setValues(previous => {
|
||||
const form = findForm(page, key);
|
||||
const base = previous[key] ?? (form ? initialValues(form) : {});
|
||||
return { ...previous, [key]: { ...base, [field]: value } };
|
||||
});
|
||||
const toggleExpanded = (key: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; });
|
||||
const toggleSelected = (key: string) => setSelected(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; });
|
||||
const execute = async (id?: string, payload?: FormValues, submittedKey?: string, submittedForm?: ExtensionForm) => {
|
||||
setError('');
|
||||
try {
|
||||
const request: { id?: string; values?: FormValues } = {};
|
||||
if (id !== undefined) request.id = id;
|
||||
if (payload !== undefined) request.values = payload;
|
||||
const next = await action.mutateAsync(request);
|
||||
queryClient.setQueryData(queryKey, next);
|
||||
if (submittedKey) setValues(previous => { const copy = { ...previous }; delete copy[submittedKey]; return copy; });
|
||||
setNoticeTab(activeTab);
|
||||
setConfirmation(null);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && reason.isAuthFailure) void api.signOut();
|
||||
else setError(reason instanceof Error ? reason.message : '操作失败');
|
||||
if (submittedKey) setValues(previous => {
|
||||
const current = { ...(previous[submittedKey] ?? {}) };
|
||||
const form = submittedForm ?? findForm(page, submittedKey);
|
||||
for (const field of form?.fields ?? []) if (field.type === 'password') current[field.key] = '';
|
||||
return { ...previous, [submittedKey]: current };
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const submit = (form: ExtensionForm, formValues: FormValues, formKey: string) => { if (form.confirm) setConfirmation({ form, values: formValues, key: formKey }); else void execute(form.id, formValues, formKey, form); };
|
||||
const selectAction = (field: ExtensionField, value: string, form: ExtensionForm, formKey: string) => { if (!field.changeAction) return; const previous = values[formKey]?.[field.key] ?? initialValues(form)[field.key]; void execute(field.changeAction, { ...(values[formKey] ?? initialValues(form)), [field.key]: value }).then(ok => { if (!ok) updateValue(formKey, field.key, previous); }); };
|
||||
if (!sourceId && !admin) return <div className="sources-screen extension-screen"><FloatingNotice message="只有管理员可以访问插件配置。" error /></div>;
|
||||
if (pageQuery.isPending) return <div className="sources-screen extension-screen"><FloatingNotice message="正在加载扩展页面…" busy /><main className="sources-body" /></div>;
|
||||
if (pageQuery.error) return <div className="sources-screen extension-screen"><main className="sources-body"><p className="notice error">{pageQuery.error instanceof Error ? pageQuery.error.message : '加载失败'}</p></main></div>;
|
||||
if (!page) return null;
|
||||
const status = busy ? '正在处理…' : error || (noticeTab === activeTab ? page.notice ?? '' : ''); const statusError = Boolean(error || (!busy && page.noticeKind === 'error'));
|
||||
return <div className="sources-screen extension-screen"><header className="sources-header"><IconButton label="返回" icon="arrow-left" onClick={() => navigate('/sources')} /><h1>{page.title}</h1><Button disabled={busy} onClick={() => void execute()}>刷新</Button></header>
|
||||
{tabs.length > 0 && <div className="extension-toolbar"><div className="extension-tabs" role="tablist" aria-label="配置分类">{tabs.map((tab, index) => <button type="button" key={tab.id} id={`extension-tab-${tab.id}`} role="tab" aria-selected={activeTab === tab.id} aria-controls={`extension-panel-${tab.id}`} tabIndex={activeTab === tab.id ? 0 : -1} disabled={busy} onClick={() => { setActiveTab(tab.id); if (tab.loadAction) void execute(tab.loadAction); }} onKeyDown={event => { let next = index; if (event.key === 'ArrowRight') next = (index + 1) % tabs.length; else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length; else if (event.key === 'Home') next = 0; else if (event.key === 'End') next = tabs.length - 1; else return; event.preventDefault(); const nextTab = tabs[next]!; setActiveTab(nextTab.id); document.getElementById(`extension-tab-${nextTab.id}`)?.focus({ preventScroll: true }); }}>{tab.title}</button>)}</div></div>}
|
||||
{status && <FloatingNotice message={status} busy={busy} error={statusError} kind="success" />}
|
||||
<main className="sources-body" aria-busy={busy} tabIndex={-1}>{page.description && <p className="extension-description">{page.description}</p>}
|
||||
<ContentView content={page} scope="page" values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} />
|
||||
{selectedTab && <div role="tabpanel" id={`extension-panel-${selectedTab.id}`} aria-labelledby={`extension-tab-${selectedTab.id}`} tabIndex={-1}>{selectedTab.description && <p className="extension-description">{selectedTab.description}</p>}<ContentView content={selectedTab} scope={`tab:${selectedTab.id}`} values={values} busy={busy} selected={selected} expanded={expanded} onChange={updateValue.bind(null)} onSubmit={submit} onSelectAction={selectAction} onToggle={toggleExpanded} onToggleSelected={toggleSelected} /></div>}
|
||||
</main>{confirmation && <FloatingConfirm title="确认操作" text={confirmation.form.confirm ?? ''} confirmText={`确认${confirmation.form.submit}`} cancelText="取消" onCancel={() => setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function findForm(page: ExtensionPage | undefined, key: string) { if (!page) return undefined; const forms = [page, ...(page.tabs ?? [])].flatMap(content => [...content.forms, ...(content.sections ?? []).flatMap(section => section.items.flatMap(item => item.forms ?? []))]); return forms.find(form => key.endsWith(`:form:${form.id}`)); }
|
||||
@@ -1,23 +1,150 @@
|
||||
import { Alert, Badge, Button, Card, Group, Loader, SegmentedControl, Stack, Text, Title } from '@mantine/core';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Modal, Stack, Text } from '@mantine/core';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||
import type { ExtensionField, SourceEntry, SourcePage } from '../../../api/sources.ts';
|
||||
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
|
||||
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
|
||||
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { mergePages } from './SourceCatalogPage.tsx';
|
||||
import { SearchableSelect } from '../../../ui/searchable-select.tsx';
|
||||
import { groupSourceResults } from '../../../ui/source-results.ts';
|
||||
import { CatalogFeedback } from '../../../ui/catalog-feedback.tsx';
|
||||
|
||||
type SourceTab = 'search' | 'sources' | 'updates' | 'plugins';
|
||||
type SearchState = 'idle' | 'searching' | 'stopped' | 'complete' | 'error' | 'limited';
|
||||
|
||||
export function SourcesPage() {
|
||||
const runtime = useRuntime();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore(state => state.verifiedUser);
|
||||
const [tab, setTab] = useState<'sources' | 'subscriptions' | 'plugins'>('sources');
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
const [tab, setTab] = useState<SourceTab>('search');
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [managing, setManaging] = useState<string | null>(null);
|
||||
const [catalogPage, setCatalogPage] = useState<SourcePage>();
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
const [catalogBusy, setCatalogBusy] = useState(false);
|
||||
const [catalogFilters, setCatalogFilters] = useState<Record<string, string>>({});
|
||||
const [detail, setDetail] = useState<SourceEntry | null>(null);
|
||||
const [searchState, setSearchState] = useState<SearchState>('idle');
|
||||
const [searchMenu, setSearchMenu] = useState(false);
|
||||
const [resultGroupKey, setResultGroupKey] = useState<string | null>(null);
|
||||
const [resultGroupQuery, setResultGroupQuery] = useState('');
|
||||
const [sourceNameQuery, setSourceNameQuery] = useState('');
|
||||
const [acquired, setAcquired] = useState<{ id: string; ref: string; title: string } | null>(null);
|
||||
const searchSession = useRef<string | null>(null);
|
||||
const searchRequest = useRef<{ query: string; filters: Record<string, string> } | null>(null);
|
||||
const searchRun = useRef<AbortController | null>(null);
|
||||
const catalogEpoch = useRef(0);
|
||||
const sources = useQuery({ queryKey: ['sources', runtime.api.baseUrl], queryFn: () => runtime.api.sources() });
|
||||
const types = useQuery({ queryKey: ['source-types', runtime.api.baseUrl], queryFn: () => runtime.api.sourceTypes(), staleTime: 5 * 60_000 });
|
||||
const subscriptions = useQuery({ queryKey: ['subscriptions', runtime.api.baseUrl, user?.id], queryFn: () => runtime.api.subscriptions(), enabled: tab === 'subscriptions' });
|
||||
const plugins = useQuery({ queryKey: ['source-plugins', runtime.api.baseUrl], queryFn: () => runtime.api.plugins(), enabled: tab === 'plugins' && user?.role === 'admin' });
|
||||
const types = useQuery({ queryKey: ['source-types', runtime.api.baseUrl], queryFn: () => runtime.api.sourceTypes(), staleTime: 300_000 });
|
||||
const subscriptions = useQuery({ queryKey: ['subscriptions', runtime.api.baseUrl], queryFn: () => runtime.api.subscriptions(), enabled: tab === 'updates' });
|
||||
const plugins = useQuery({ queryKey: ['source-plugins', runtime.api.baseUrl], queryFn: () => runtime.api.plugins(), enabled: tab === 'plugins' && admin });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const refresh = () => { void Promise.all([sources.refetch(), types.refetch(), subscriptions.refetch(), plugins.refetch()]); };
|
||||
const configure = async (id: string, enabled: boolean) => { setBusy(id); try { await runtime.api.configureSubscription(id, { enabled }); await queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); } finally { setBusy(null); } };
|
||||
const check = async (id: string) => { setBusy(id); try { await runtime.api.refreshPublication(id); await queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); } finally { setBusy(null); } };
|
||||
const sourceTypeLabel = (id: string) => types.data?.find(item => item.id === id)?.label || id;
|
||||
return <Stack p="md" maw={1100} mx="auto"><Group justify="space-between"><Title order={1}>书源</Title><Group><Button component={Link} to="/sources/search" variant="filled">搜书</Button><Button variant="light" onClick={refresh}>刷新</Button></Group></Group><SegmentedControl value={tab} onChange={value => setTab(value as typeof tab)} data={[{ value: 'sources', label: '已配置来源' }, { value: 'subscriptions', label: '自动追更' }, ...(user?.role === 'admin' ? [{ value: 'plugins', label: '插件' }] : [])]} />{tab === 'sources' && <Stack>{user?.role === 'admin' && <Button component={Link} to="/sources/manage/new" variant="light">添加来源</Button>}{sources.isPending && <Loader />}{sources.error && <Alert color="red">{sources.error.message}</Alert>}{sources.data?.length === 0 && <Alert>还没有配置书源。请添加来源或启用插件。</Alert>}{sources.data?.map(source => <Card withBorder key={source.id}><Group justify="space-between"><div><Text fw={600}>{source.name}</Text><Text size="sm" c="dimmed">{sourceTypeLabel(source.sourceType)} · {source.enabled ? '已启用' : '已停用'}</Text></div><Group><Badge color={source.enabled ? 'green' : 'gray'}>{source.enabled ? '可用' : '停用'}</Badge>{source.enabled && source.descriptor && <Button component={Link} to={`/sources/${encodeURIComponent(source.id)}/browse`} size="compact-sm">浏览</Button>}{source.descriptor?.credentialKeys?.length ? <Button component={Link} to={`/sources/${encodeURIComponent(source.id)}/credentials`} size="compact-sm" variant="light">凭据</Button> : null}{user?.role === 'admin' && <Button component={Link} to={`/sources/manage/${encodeURIComponent(source.id)}`} size="compact-sm" variant="subtle">管理</Button>}</Group></Group></Card>)}</Stack>}{tab === 'subscriptions' && <Stack>{subscriptions.isPending && <Loader />}{subscriptions.error && <Alert color="red">{subscriptions.error.message}</Alert>}{subscriptions.data?.length === 0 && <Alert>还没有开启自动追更。可从书源获取章节书后开启。</Alert>}{subscriptions.data?.map(item => <Card withBorder key={item.bookId}><Group justify="space-between" align="flex-start"><div><Text fw={600}>{item.title}</Text><Text size="sm" c="dimmed">{item.newChapters ? `新增 ${item.newChapters} 章 · ` : ''}{item.lastSuccessAt ? `上次成功 ${new Date(item.lastSuccessAt).toLocaleString()}` : '尚未检查'}</Text>{item.lastError && <Text size="sm" c="red">{item.lastError}</Text>}</div><Group><Button size="compact-sm" loading={busy === item.bookId} onClick={() => void check(item.bookId)}>立即检查</Button><Button size="compact-sm" variant={item.enabled ? 'light' : 'filled'} loading={busy === item.bookId} onClick={() => void configure(item.bookId, !item.enabled)}>{item.enabled ? '关闭追更' : '开启追更'}</Button></Group></Group></Card>)}</Stack>}{tab === 'plugins' && <Stack><Button component={Link} to="/sources/plugins" variant="light">插件安装与管理</Button>{plugins.isPending && <Loader />}{plugins.error && <Alert color="red">{plugins.error.message}</Alert>}{plugins.data?.map(plugin => <Card withBorder key={plugin.pluginId}><Group justify="space-between"><div><Text fw={600}>{plugin.name || plugin.pluginId}</Text><Text size="sm" c="dimmed">{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</Text>{plugin.error && <Text size="sm" c="red">{plugin.error.message}</Text>}</div>{!plugin.builtin && <Button size="compact-sm" variant="light" loading={busy === plugin.pluginId} onClick={async () => { setBusy(plugin.pluginId); try { await runtime.api.enablePlugin(plugin.pluginId, !plugin.enabled); await plugins.refetch(); } finally { setBusy(null); } }}>{plugin.enabled ? '停用' : '启用'}</Button>}</Group></Card>)}</Stack>}</Stack>;
|
||||
const enabledSources = (sources.data ?? []).filter(source => source.enabled && source.descriptor);
|
||||
const selectedSourceId = sourceId || enabledSources.find(source => source.isDefault)?.id || enabledSources[0]?.id || '';
|
||||
const selectedSource = enabledSources.find(source => source.id === selectedSourceId);
|
||||
const sourceFilters = useQuery({ queryKey: ['source-filters', runtime.api.baseUrl, selectedSourceId], queryFn: () => runtime.api.sourceFilters(selectedSourceId), enabled: !!selectedSourceId && !!selectedSource?.descriptor?.capabilities.includes('search.filters') });
|
||||
const detailQuery = useQuery({ queryKey: ['source-detail', runtime.api.baseUrl, selectedSourceId, detail?.ref], queryFn: () => runtime.api.sourceDetail(selectedSourceId, detail!.ref), enabled: !!detail });
|
||||
const acquire = useMutation({ mutationFn: ({ ref, option }: { ref: string; option?: string }) => runtime.api.acquireSource(selectedSourceId, ref, option), onError: reason => setCatalogError(reason instanceof Error ? reason.message : '加入书架失败') });
|
||||
const grouped = searchState !== 'idle' && !(selectedSource?.descriptor?.builtin && selectedSource.sourceType === 'local');
|
||||
const results = groupSourceResults(catalogPage?.items ?? [], grouped);
|
||||
const activeGroup = results.find(group => group.key === resultGroupKey);
|
||||
const canResume = !!searchSession.current && !!searchRequest.current && searchState !== 'limited'
|
||||
&& !!(catalogPage?.nextCursor || searchState === 'stopped' || searchState === 'error')
|
||||
&& keyword.trim() === searchRequest.current.query && JSON.stringify(catalogFilters) === JSON.stringify(searchRequest.current.filters);
|
||||
const resume = canResume && (searchState === 'stopped' || searchState === 'error');
|
||||
const filterOptions = (field: ExtensionField) => {
|
||||
const parent = catalogFilters[field.changeAction ?? field.dependsOn ?? ''];
|
||||
return (field.options ?? []).filter(option => !parent || !option.parentValues || option.parentValues.includes(parent));
|
||||
};
|
||||
useEffect(() => { if (!sourceId && selectedSourceId) setSourceId(selectedSourceId); }, [selectedSourceId, sourceId]);
|
||||
const sourceTypeLabel = (id: string) => types.data?.find(item => item.id === id)?.label ?? id;
|
||||
const loadCatalog = async (ref?: string) => {
|
||||
if (!selectedSourceId || !selectedSource?.descriptor?.capabilities.includes('browse')) return;
|
||||
const epoch = ++catalogEpoch.current;
|
||||
setCatalogBusy(true); setCatalogError(''); setSearchState('idle'); setResultGroupKey(null); setAcquired(null);
|
||||
try { const page = await runtime.api.sourceCatalog(selectedSourceId, ref ? { ref } : {}); if (catalogEpoch.current === epoch) setCatalogPage(page); }
|
||||
catch (reason) { if (catalogEpoch.current === epoch) setCatalogError(reason instanceof Error ? reason.message : '目录加载失败'); }
|
||||
finally { if (catalogEpoch.current === epoch) setCatalogBusy(false); }
|
||||
};
|
||||
const stopSearch = () => { searchRun.current?.abort(); searchRun.current = null; setCatalogBusy(false); setSearchState('stopped'); };
|
||||
const searchSources = async (append = false) => {
|
||||
const request = append ? searchRequest.current : { query: keyword.trim(), filters: { ...catalogFilters } };
|
||||
if (!request?.query || !selectedSource || catalogBusy || (append && !canResume)) return;
|
||||
const run = new AbortController(); searchRun.current = run; ++catalogEpoch.current;
|
||||
const current = () => searchRun.current === run;
|
||||
setCatalogBusy(true); setCatalogError(''); setSearchState('searching'); setSearchMenu(false);
|
||||
if (!append) { setCatalogPage(undefined); searchSession.current = crypto.randomUUID(); setAcquired(null); setResultGroupKey(null); }
|
||||
searchRequest.current = request;
|
||||
try {
|
||||
let merged: SourcePage | undefined = append ? catalogPage : undefined;
|
||||
for await (const page of runtime.api.searchSource(selectedSource.id, { ...request, sessionId: searchSession.current!, ...(append && catalogPage?.nextCursor ? { cursor: catalogPage.nextCursor } : {}), resultLimit: 10000 }, { signal: run.signal })) {
|
||||
if (!current()) return;
|
||||
merged = mergePages(merged, page); setCatalogPage(merged);
|
||||
if (page.limitReached) { setSearchState('limited'); break; }
|
||||
}
|
||||
if (current()) setSearchState(previous => previous === 'limited' ? previous : 'complete');
|
||||
} catch (reason) { if (current()) { setCatalogError(reason instanceof Error ? reason.message : '搜索失败,已保留找到的书籍。'); setSearchState('error'); } }
|
||||
finally { if (current()) { searchRun.current = null; setCatalogBusy(false); } }
|
||||
};
|
||||
const acquireEntry = (entry: SourceEntry, option?: string) => acquire.mutate(option === undefined ? { ref: entry.ref } : { ref: entry.ref, option }, { onSuccess: result => { if (result.kind === 'action-required') setCatalogError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) { setAcquired({ id: result.publicationId, ref: entry.ref, title: entry.title }); void queryClient.invalidateQueries({ queryKey: ['shelf'] }); } } });
|
||||
const mutateSubscription = async (id: string, enabled?: boolean) => { setBusy(id); try { if (enabled === undefined) await runtime.api.refreshPublication(id); else await runtime.api.configureSubscription(id, { enabled }); await queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); } finally { setBusy(null); } };
|
||||
const updateSource = async (id: string, patch: { enabled?: boolean; isDefault?: boolean }) => { setBusy(id); try { await runtime.api.saveSource(id, patch); await sources.refetch(); setManaging(null); } finally { setBusy(null); } };
|
||||
useEffect(() => {
|
||||
++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null;
|
||||
setCatalogBusy(false); setCatalogPage(undefined); setCatalogError(''); setCatalogFilters({}); setDetail(null); searchSession.current = null;
|
||||
setSearchState('idle'); setKeyword(''); setResultGroupKey(null); setResultGroupQuery(''); setSourceNameQuery(''); searchRequest.current = null;
|
||||
if (selectedSource?.descriptor?.capabilities.includes('browse')) void loadCatalog();
|
||||
return () => { ++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null; };
|
||||
}, [selectedSourceId]);
|
||||
useEffect(() => { if (detail && detailQuery.data) setDetail({ ...detail, ...detailQuery.data }); }, [detailQuery.data]);
|
||||
return <div className="sources-screen sources-hub">
|
||||
<header className="sources-header">
|
||||
<Link className="icon-button" to="/shelf" aria-label="返回" title="返回"><ArrowLeft size={20} /></Link>
|
||||
<h1>书源与追更</h1>
|
||||
<span aria-hidden="true" />
|
||||
</header>
|
||||
<nav className="sources-tabs" role="tablist" aria-label="书源功能">
|
||||
{([['search', '搜书'], ['sources', '书源管理'], ['updates', '自动追更'], ...(admin ? [['plugins', '插件管理']] : [])] as Array<[SourceTab, string]>).map(([id, label]) => <button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>{label}{id === 'updates' && (subscriptions.data ?? []).some(item => item.newChapters > 0) && <span className="sources-tab-dot" aria-label="有更新" />}</button>)}
|
||||
</nav>
|
||||
<main className="sources-body" role="tabpanel">
|
||||
{tab === 'search' && <>
|
||||
<section className="sources-card source-picker">
|
||||
<div><h2>搜书</h2><p className="muted">选择来源,发现想读的书。</p></div>
|
||||
<SearchableSelect label="选择来源" value={selectedSourceId} options={enabledSources.map(source => ({ value: source.id, label: source.name + (source.isDefault ? '(默认)' : '') }))} onChange={setSourceId} />
|
||||
{!selectedSourceId && <p className="muted">{enabledSources.length ? '选择来源后,即可搜索书籍或浏览目录。' : '暂无可用来源,请先添加或启用来源。'}</p>}
|
||||
</section>
|
||||
{selectedSource && <section className="sources-card source-catalog"><h2>搜索与浏览</h2>
|
||||
{catalogError && <Alert color="red" withCloseButton onClose={() => setCatalogError('')}>{catalogError}</Alert>}
|
||||
{selectedSource.descriptor?.capabilities.includes('search') && <form className="sources-search" onSubmit={event => { event.preventDefault(); void searchSources(resume); }}>
|
||||
{sourceFilters.data?.filter(field => field.type === 'select').map(field => <SearchableSelect key={field.key} label={field.label} value={catalogFilters[field.key] ?? ''} disabled={catalogBusy} options={filterOptions(field)} onChange={value => setCatalogFilters(previous => { const next = { ...previous, [field.key]: value }; for (const dependent of sourceFilters.data ?? []) { if ((dependent.changeAction ?? dependent.dependsOn) === field.key && !dependent.options?.some(option => option.value === next[dependent.key] && (!option.parentValues || !value || option.parentValues.includes(value)))) next[dependent.key] = ''; } return next; })} />)}
|
||||
<label className="source-keyword">搜索书籍<input type="search" placeholder="输入书名或作者" required disabled={catalogBusy} value={keyword} onChange={event => setKeyword(event.currentTarget.value)} /></label>
|
||||
{searchState === 'searching' ? <button className="button search-stop" type="button" onClick={stopSearch}>停止搜索</button> : <div className="search-split"><button className="button primary" type="submit" disabled={catalogBusy}><Search size={16} />{resume ? '继续搜索' : '搜索'}</button>{searchRequest.current && <><button className="button primary search-menu-toggle" type="button" aria-label="搜索选项" aria-haspopup="menu" aria-expanded={searchMenu} onClick={() => setSearchMenu(open => !open)}><ChevronRight size={16} /></button>{searchMenu && <div className="search-menu" role="menu" aria-label="搜索选项"><button type="button" role="menuitem" disabled={!canResume} onClick={() => void searchSources(true)}>继续搜索<small>保留结果,继续未完成的来源</small></button><button type="button" role="menuitem" onClick={() => void searchSources(false)}>重新搜索<small>清空结果,从头搜索</small></button></div>}</>}</div>}
|
||||
</form>}
|
||||
{searchState !== 'idle' && <div className="search-progress" role="status"><span>{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[searchState]}{catalogPage?.batch ? ` · 已检查 ${catalogPage.batch.completed} / ${catalogPage.batch.total} 个来源` : ''} · 已找到 {results.length} 本书</span>{catalogPage?.batch && <progress aria-label="书源搜索进度" max={Math.max(1, catalogPage.batch.total)} value={catalogPage.batch.completed} />}</div>}
|
||||
{catalogPage && grouped && <label className="catalog-group-filter">筛选分组<input type="search" placeholder="搜索书名或作者" value={resultGroupQuery} onChange={event => setResultGroupQuery(event.currentTarget.value)} /></label>}
|
||||
{catalogPage && <CatalogFeedback page={catalogPage} count={results.length} merged={searchState !== 'idle'} searching={searchState === 'searching'} />}
|
||||
{catalogPage?.navigation && <div className="source-navigation">{catalogPage.navigation.map(item => <Button className="button" key={item.ref} variant="subtle" onClick={() => void loadCatalog(item.ref)}>{item.title}</Button>)}</div>}
|
||||
{catalogBusy && !catalogPage && <FloatingNotice message="正在加载目录…" busy />}
|
||||
{catalogPage && <div className="source-results">{results.filter(group => { const q = resultGroupQuery.trim().toLocaleLowerCase(); return !q || group.entry.title.toLocaleLowerCase().includes(q) || group.entry.authors?.some(author => author.toLocaleLowerCase().includes(q)); }).map(group => <article className="sources-row catalog-book" key={group.key}><div><strong>{group.entry.title}</strong><small>{group.entry.authors?.join(' / ') || '作者未知'}</small>{!grouped && group.entry.description && <p className="source-description">{group.entry.description}</p>}</div>{grouped ? <button className="button catalog-source-count" type="button" onClick={() => setResultGroupKey(group.key)}><span>{group.entries.length} 条书源</span><ChevronRight size={16} /></button> : <div className="sources-actions"><button className="button" type="button" onClick={() => setDetail(group.entry)}>详情</button>{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => <button className="button primary" type="button" key={option.id} disabled={option.available === false || acquire.isPending} onClick={() => acquireEntry(group.entry, option.id)}>{option.label}</button>)}</div>}</article>)}</div>}
|
||||
{catalogPage && !catalogBusy && !catalogPage.items.length && <div className="catalog-empty"><strong>没有找到匹配书籍</strong><p>试试其他关键词,或调整搜索范围。</p></div>}
|
||||
{searchState === 'complete' && catalogPage?.nextCursor && <div className="catalog-pagination"><button className="button" type="button" disabled={!canResume} onClick={() => void searchSources(true)}>加载更多结果</button></div>}
|
||||
{acquired && <button className="button" type="button" onClick={() => navigate(`/book/${encodeURIComponent(acquired.id)}`)}>阅读《{acquired.title}》</button>}
|
||||
</section>}
|
||||
</>}
|
||||
{tab === 'sources' && <section className="sources-card sources-list">
|
||||
<div className="sources-list-heading"><h2>我的来源 <span className="source-count">{sources.data?.length ?? 0}</span></h2>{admin && <Link className="button source-add" to="/sources/manage/new"><Plus size={16} />添加来源</Link>}</div>
|
||||
{sources.isPending && <FloatingNotice message="正在加载书源…" busy />}{sources.error && <p className="notice error">{sources.error.message}</p>}{!sources.isPending && !sources.data?.length && <p className="muted">还没有配置书源。请添加来源或启用插件。</p>}
|
||||
{(sources.data ?? []).map(source => <article className="sources-row source-entry" key={source.id}><div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}{source.isDefault && <span className="source-default-badge">默认</span>}</strong><small>{source.descriptor?.label ?? sourceTypeLabel(source.sourceType)}</small><span className={'source-state' + (source.enabled ? ' is-enabled' : '')}>{source.enabled ? '已启用' : '已暂停'}</span></div><div className="source-entry-actions">{source.descriptor?.credentialKeys?.length ? <Link className="button" to={'/sources/' + encodeURIComponent(source.id) + '/credentials'}>书源登录</Link> : null}{admin && <button className="source-manage-trigger" type="button" aria-expanded={managing === source.id} onClick={() => setManaging(current => current === source.id ? null : source.id)}>管理<ChevronRight size={16} /></button>}</div></div>{admin && managing === source.id && <div className="source-management" role="group" aria-label={`${source.name}管理`}>{source.descriptor?.extensions?.pages?.map(page => <Link className="source-management-link" key={page.id} to={'/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)}>{page.title}<ChevronRight size={16} /></Link>)}<Link className="source-management-link" to={'/sources/manage/' + encodeURIComponent(source.id)}>基本设置<ChevronRight size={16} /></Link><button className="source-management-link" type="button" disabled={busy === source.id || (!source.isDefault && (!source.enabled || !source.descriptor?.capabilities.includes('search')))} onClick={() => void updateSource(source.id, { isDefault: !source.isDefault })}>{source.isDefault ? '取消默认' : '设为默认'}</button><button className="source-management-link source-toggle" type="button" disabled={busy === source.id} onClick={() => void updateSource(source.id, { enabled: !source.enabled })}>{source.enabled ? '暂停' : '启用'}</button></div>}</article>)}
|
||||
</section>}
|
||||
{tab === 'updates' && <section className="sources-card"><h2>自动追更</h2><p className="notice">服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。</p>{subscriptions.isPending && <FloatingNotice message="正在加载追更列表…" busy />}{!subscriptions.isPending && !subscriptions.data?.length && <p>先从章节书源加入书籍,再开启追更。</p>}{(subscriptions.data ?? []).map(item => <article className="sources-row" key={item.bookId}><div><strong>{item.title}</strong><small>{item.newChapters ? '新增 ' + item.newChapters + ' 章 · ' : ''}上次成功:{item.lastSuccessAt ? new Date(item.lastSuccessAt).toLocaleString() : '尚未检查'}</small><small>{item.enabled ? '下次检查:' + new Date(item.nextCheckAt).toLocaleString() : '自动追更已关闭'}{item.lastError ? ' · 检查失败:' + item.lastError : ''}</small></div><div className="sources-actions"><label className="source-interval">检查间隔<select aria-label={`${item.title}检查间隔`} value={item.intervalMinutes} disabled={busy === item.bookId} onChange={event => { const intervalMinutes = Number(event.currentTarget.value); setBusy(item.bookId); void runtime.api.configureSubscription(item.bookId, { intervalMinutes }).then(() => queryClient.invalidateQueries({ queryKey: ['subscriptions'] })).finally(() => setBusy(null)); }}><option value="15">15 分钟</option><option value="60">1 小时</option><option value="360">6 小时</option><option value="1440">1 天</option></select></label><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId, !item.enabled)}>{item.enabled ? '关闭追更' : '开启追更'}</button><button className="button" type="button" disabled={busy === item.bookId} onClick={() => void mutateSubscription(item.bookId)}>立即检查</button><Link className="button" to={'/book/' + encodeURIComponent(item.bookId)}>阅读</Link></div></article>)}</section>}
|
||||
{tab === 'plugins' && admin && <section className="sources-card"><h2>插件管理</h2><Link className="button primary" to="/sources/plugins">插件安装与管理</Link>{plugins.isPending && <FloatingNotice message="正在加载插件列表…" busy />}{(plugins.data ?? []).map(plugin => <article className="sources-row" key={plugin.pluginId}><div><strong>{plugin.name || plugin.pluginId}</strong><small>{plugin.version || '未知版本'} · {plugin.builtin ? '内置' : plugin.enabled ? '已启用' : '已停用'}</small></div></article>)}</section>}
|
||||
</main>
|
||||
{activeGroup && <Modal opened onClose={() => { setResultGroupKey(null); setSourceNameQuery(''); }} title="书源列表"><Stack><Text>{activeGroup.entry.title} · {activeGroup.entries.length} 条书源</Text><input className="catalog-source-search" type="search" placeholder="搜索书源" value={sourceNameQuery} onChange={event => setSourceNameQuery(event.currentTarget.value)} />{activeGroup.entries.filter(entry => (entry.sourceName ?? selectedSource?.name ?? '').toLocaleLowerCase().includes(sourceNameQuery.trim().toLocaleLowerCase())).map(entry => { const option = entry.options?.[0]; return <article className="catalog-source-item" key={entry.ref}><div><strong>{entry.sourceName ?? selectedSource?.name}</strong><p className="source-description">最新章节:{entry.latestChapter || '暂无信息'}</p></div><div className="sources-actions"><Button className="button" onClick={() => setDetail(entry)}>详情</Button><Button className="button primary" disabled={option?.available === false} onClick={() => acquireEntry(entry, option?.id)}>{option?.label ?? '加入书架'}</Button></div></article>; })}</Stack></Modal>}
|
||||
<Modal opened={!!detail} onClose={() => setDetail(null)} title={detail?.title ?? '书籍详情'}><Stack>{detailQuery.isPending && <FloatingNotice message="正在读取书籍详情…" busy />}{detailQuery.error && <Alert color="red">{detailQuery.error.message}</Alert>}{detail && <><Text>{detail.description || '暂无简介'}</Text><Text size="sm" c="dimmed">{detail.authors?.join('、') || '作者未知'}</Text>{detail.options?.map(option => <Button key={option.id} disabled={option.available === false} loading={acquire.isPending} onClick={() => acquireEntry(detail, option.id)}>{option.label}</Button>)}{(!detail.options || detail.options.length === 0) && <Button loading={acquire.isPending} onClick={() => acquireEntry(detail)}>加入书架</Button>}</>}</Stack></Modal>
|
||||
</div>;
|
||||
}
|
||||
@@ -240,6 +240,7 @@ body {
|
||||
background: var(--reader-paper);
|
||||
color: var(--reader-ink);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||
line-height: normal;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@@ -691,6 +692,7 @@ book-content {
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
@@ -734,6 +736,13 @@ book-content {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Route links use the same visual control language as their button counterparts. */
|
||||
.icon-button,
|
||||
.collection-link,
|
||||
.book-open {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.panel-header .icon-text-button {
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
@@ -1495,6 +1504,7 @@ book-content {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2732,21 +2742,35 @@ book-content {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.login-screen { background: radial-gradient(ellipse at 15% 5%, #a1835720, transparent 60%), var(--reader-paper); padding: 2rem 1rem; }
|
||||
.login-card { max-width: 27rem; width: 100%; padding: 2rem; border: 1px solid var(--reader-line); border-radius: 1.4rem; background: var(--reader-paper); box-shadow: 0 16px 60px #00000006; }
|
||||
.login-heading { margin-bottom: 1.75rem; }
|
||||
.login-brand { display: flex; align-items: center; gap: .65rem; font-size: 1.1rem; font-weight: 750; color: var(--reader-accent); }
|
||||
.login-brand span { font-size: .75rem; color: var(--reader-muted); font-weight: 400; border-left: 1px solid var(--reader-line); padding-left: .65rem; }
|
||||
.login-heading h1 { font-size: 1.65rem; line-height: 1.3; margin: 1.6rem 0 .6rem; }
|
||||
.login-heading p { font-size: .85rem; line-height: 1.7; margin: 0; }
|
||||
.login-screen { display:flex; align-items:flex-start; min-height:100vh; box-sizing:border-box; overflow-y:auto; background:var(--reader-paper); padding:3.2rem 1.5rem 4rem; }
|
||||
.login-card { max-width:35rem; width:100%; margin:0 auto; padding:0; border:0; border-radius:0; background:transparent; box-shadow:none; }
|
||||
.login-heading { margin-bottom:2.25rem; }
|
||||
.login-brand { font-size:2.35rem; line-height:1.15; font-weight:750; letter-spacing:-.035em; color:var(--reader-ink); }
|
||||
.login-heading p { font-size:1.05rem; line-height:1.7; margin:.3rem 0 0; color:#7990aa; }
|
||||
.login-card .field { display: flex; flex-direction: column; align-items: stretch; gap: .5rem; margin: 0 0 1rem; }
|
||||
.login-card .field label { font-size: .85rem; color: var(--reader-ink); }
|
||||
.login-card input { width: 100%; min-width: 0; min-height: 48px; padding: .75rem .9rem; font-size: 16px; border-radius: .7rem; }
|
||||
.login-card .field label { font-size: 1rem; color: var(--reader-ink); }
|
||||
.login-card .field label em { color:#e66d67; font-style:normal; }
|
||||
.login-card input { width: 100%; min-width: 0; min-height: 46px; box-sizing:border-box; padding: .65rem .85rem; font-size: 16px; border:1px solid #cfd6df; border-radius:.3rem; background:#fff; color:var(--reader-ink); }
|
||||
.login-card input:focus { border-color:#8e5d36; outline:2px solid color-mix(in srgb,#8e5d36 28%,transparent); outline-offset:1px; }
|
||||
.login-connection { margin-bottom: 1.4rem; border-bottom: 1px solid var(--reader-line); padding-bottom: .9rem; }
|
||||
.login-connection summary { font-size: .8rem; color: var(--reader-muted); cursor: pointer; }
|
||||
.login-connection summary span { float: right; font-size: .75rem; }
|
||||
.login-connection summary { font-size: .9rem; color: var(--reader-muted); cursor: pointer; }
|
||||
.login-connection summary span { float: right; font-size: .85rem; color:var(--reader-ink); }
|
||||
.login-connection[open] .field { margin: 1rem 0 0; }
|
||||
.login-submit { width: 100%; min-height: 50px; margin-top: .35rem; font-size: 1rem; border-radius: .75rem; }
|
||||
.login-server-input { position:relative; }
|
||||
.login-server-input input { padding-right:3.25rem; }
|
||||
.login-server-status { position:absolute; right:.45rem; top:50%; transform:translateY(-50%); display:grid; place-items:center; min-width:1.8rem; height:1.8rem; border-radius:50%; background:#51b95b; color:#fff; font-size:.8rem; font-weight:700; }
|
||||
.login-server-status.is-probing { background:#9aa3ae; }
|
||||
.login-server-meta { display:flex; align-items:center; justify-content:space-between; color:#70849b; font-size:.88rem; margin-top:.35rem; }
|
||||
.login-password-input { position:relative; }
|
||||
.login-password-input input { padding-right:3rem; }
|
||||
.login-password-toggle { position:absolute; right:.15rem; top:50%; transform:translateY(-50%); display:grid; place-items:center; width:2.5rem; height:2.5rem; min-height:0; padding:0; border:0; background:transparent; color:#7a8795; cursor:pointer; }
|
||||
.login-password-toggle:hover { color:var(--reader-ink); }
|
||||
.login-submit { width: 100%; min-height: 50px; margin-top: .8rem; font-size: 1rem; border-radius: .55rem; background:#538be4; border-color:#538be4; }
|
||||
.login-submit:hover { background:#467ed7; border-color:#467ed7; }
|
||||
.login-card .login-notice { margin-top:.95rem; padding:1rem 1.1rem; border-radius:.35rem; background:#e8eef6; color:#27384e; font-size:1rem; line-height:1.6; }
|
||||
.login-card .login-notice.is-warning { background:#f3eee4; color:#76634d; }
|
||||
.login-card .login-notice.is-error { background:#f8e6e3; color:#933c31; }
|
||||
.login-register-hint { color:var(--reader-muted); font-size:.82rem; }
|
||||
.login-session-hint { text-align: center; font-size: .72rem; color: var(--reader-muted); line-height: 1.6; margin: .85rem 0 1.4rem; }
|
||||
.login-switch { display: flex; align-items: center; justify-content: center; gap: .25rem; font-size: .85rem; color: var(--reader-muted); }
|
||||
.login-switch button { min-height: 44px; border: 0; padding: .5rem; background: transparent; color: var(--reader-accent); font: inherit; cursor: pointer; }
|
||||
@@ -2761,7 +2785,7 @@ book-content {
|
||||
.user-manager-list li { padding: 1rem; border: 1px solid var(--reader-line); border-radius: .75rem; overflow-wrap: anywhere; }
|
||||
.user-manager-list p { margin: .5rem 0; font-size: .85rem; }
|
||||
.user-management summary { cursor: pointer; min-height: 44px; padding-block: .65rem; }
|
||||
@media (max-width: 480px) { .login-screen { padding: 1.5rem 1rem; } .login-card { padding: 1.5rem; } }
|
||||
@media (max-width: 700px) { .login-screen { padding: 2rem 1rem 3rem; } .login-card { margin-inline:auto; } }
|
||||
|
||||
.notice {
|
||||
background: color-mix(in srgb, var(--reader-accent) 12%, var(--reader-surface));
|
||||
@@ -3150,11 +3174,18 @@ book-content {
|
||||
*/
|
||||
.manager-check {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid color-mix(in srgb, var(--reader-muted) 55%, transparent);
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
/* Always laid out, only visible while something is selected: toggling
|
||||
`display` would shift every row's indent the moment a selection begins,
|
||||
which reads as the list jumping under the finger that started it. */
|
||||
@@ -3191,6 +3222,11 @@ book-content {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.manager-check:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.manager-row[data-selected='true'] .manager-check::after {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -3559,6 +3595,12 @@ book-content {
|
||||
.sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); }
|
||||
.sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; }
|
||||
.sources-row small { display:block; margin-top:.35rem; opacity:.75; }
|
||||
.source-interval { display:inline-flex !important; flex-direction:row !important; align-items:center; gap:.45rem !important; margin:0 !important; color:var(--reader-muted); font-size:.8rem; }
|
||||
.source-interval select { min-height:40px !important; width:auto !important; padding:.45rem .55rem !important; }
|
||||
.source-catalog-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; margin:.5rem 0 1rem; }
|
||||
.source-navigation { flex-wrap:wrap; margin:.5rem 0 1rem; }
|
||||
.library-files-screen .manager-status { padding:.65rem 1rem 1rem; }
|
||||
.library-files-screen .collection-link.is-disabled { opacity:.5; pointer-events:none; }
|
||||
.sources-actions { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; }
|
||||
.sources-actions label { margin:0; }
|
||||
.sources-card .sources-consent { flex-direction:row; align-items:center; }
|
||||
@@ -3825,10 +3867,51 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.collection-links { display: flex; gap: .65rem; margin-top: .85rem; }
|
||||
.shelf-links { max-width: calc(var(--shelf-measure) - 1.8rem); margin: 0 auto .9rem; padding-inline: calc(.9rem + var(--safe-left)) calc(.9rem + var(--safe-right)); }
|
||||
.collection-link.button { display: flex; align-items: center; gap: .6rem; flex: 1; min-width: 0; min-height: 48px; padding: .7rem .85rem; font-size: .875rem; border-radius: .8rem; background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); border-color: color-mix(in srgb, var(--reader-line) 65%, transparent); text-align: start; }
|
||||
.collection-link.button, .collection-link.button > span { white-space: nowrap; }
|
||||
.collection-link > span { flex: 1; }
|
||||
.collection-link[hidden] { display: none; }
|
||||
.collection-link > .icon:first-child { color: var(--reader-accent); }
|
||||
.collection-link > .icon:last-child { color: var(--reader-muted); font-size: .8rem; }
|
||||
.shelf-links .collection-link.button { min-height: 44px; padding: .6rem .75rem; border-radius: .65rem; box-shadow: none; }
|
||||
.shelf-links .collection-link.button > svg { flex: 0 0 auto; width: 17px; height: 17px; }
|
||||
.shelf-links .collection-link.button > span:last-child { flex: 0 0 auto; font-size: 1rem; line-height: 1; }
|
||||
.plugin-management-screen .sources-header { flex: 0 0 auto; }
|
||||
.plugin-management-body { gap: 1rem; }
|
||||
.plugin-install-card, .plugin-list-card { display: grid; gap: 1rem; }
|
||||
.plugin-card-heading, .plugin-list-heading { display: flex; align-items: flex-start; gap: .75rem; }
|
||||
.plugin-card-heading > svg { color: var(--reader-accent); flex: 0 0 auto; margin-top: .2rem; }
|
||||
.plugin-card-heading h2, .plugin-list-heading h2 { margin: 0; }
|
||||
.plugin-card-heading p { margin: .3rem 0 0; }
|
||||
.plugin-install-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .9rem; }
|
||||
.plugin-install-grid label { display: grid; gap: .45rem; font-size: .82rem; color: var(--reader-muted); }
|
||||
.plugin-install-grid input[type=text], .plugin-install-grid input[type=file] { width: 100%; min-height: 42px; box-sizing: border-box; }
|
||||
.plugin-trust { display: flex; align-items: center; gap: .55rem; font-size: .84rem; color: var(--reader-muted); }
|
||||
.plugin-trust input { width: 18px; height: 18px; margin: 0; }
|
||||
.plugin-install-submit { justify-self: start; display: inline-flex; align-items: center; gap: .45rem; }
|
||||
.plugin-install { margin: 0; border: 1px solid var(--reader-line); border-radius: .7rem; overflow: hidden; }
|
||||
.plugin-install-tabs { display:flex; border-bottom:1px solid var(--reader-line); }
|
||||
.plugin-install-tabs button { flex:1; min-height:44px; padding:.65rem .8rem; border:0; border-bottom:2px solid transparent; background:transparent; color:var(--reader-muted); font:inherit; cursor:pointer; }
|
||||
.plugin-install-tabs button[aria-selected=true] { border-bottom-color:var(--reader-accent); color:var(--reader-ink); font-weight:650; }
|
||||
.plugin-install-form { display:flex; flex-direction:column; gap:.75rem; padding:1rem; }
|
||||
.plugin-install-form label { display:grid; gap:.4rem; font-size:.85rem; color:var(--reader-muted); }
|
||||
.plugin-install-form input { width:100%; min-height:42px; box-sizing:border-box; }
|
||||
.plugin-install-form small { color:var(--reader-muted); line-height:1.5; }
|
||||
.plugin-list-heading { align-items: center; }
|
||||
.plugin-list-heading .source-count { margin-left: .25rem; }
|
||||
.plugin-list { display: grid; gap: .55rem; }
|
||||
.plugin-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.plugin-row-info { display: grid; gap: .22rem; min-width: 0; }
|
||||
.plugin-row-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.plugin-row-info small { color: var(--reader-muted); }
|
||||
.plugin-row-info .plugin-error { color: var(--reader-danger, #a4473c); }
|
||||
.plugin-row-actions { flex: 0 0 auto; display: flex; align-items: center; gap: .5rem; }
|
||||
.plugin-row-actions .button { display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.plugin-row-actions .button.danger { color: var(--reader-danger, #a4473c); }
|
||||
@media (max-width: 640px) {
|
||||
.plugin-install-grid { grid-template-columns: 1fr; }
|
||||
.plugin-row { align-items: stretch; flex-direction: column; }
|
||||
.plugin-row-actions { justify-content: flex-start; }
|
||||
}
|
||||
.shelf-screen .shelf-search input, .library-search input { border-radius: .85rem; min-height: 48px; }
|
||||
.library-browse-screen .library-search { margin-top: .35rem; }
|
||||
.library-path { margin-top: .75rem; padding: .25rem 0 0; }
|
||||
@@ -3843,13 +3926,28 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.file-manager-header .manager-crumbs { margin-top: .75rem; padding: .25rem 0; }
|
||||
.file-manager-header .manager-crumb { min-height: 44px; }
|
||||
.library-files-screen .manager-list { margin: .5rem 1rem; border: 1px solid var(--reader-line); border-radius: 1rem; overflow: hidden; }
|
||||
.library-files-screen .manager-check { opacity: 1; pointer-events: auto; }
|
||||
.library-files-screen .manager-row { grid-template-columns: 24px minmax(0, 1fr) 44px; gap: .4rem .65rem; padding: .9rem .75rem; }
|
||||
.library-files-screen .manager-check { grid-column: 1; grid-row: 1 / 3; }
|
||||
.library-files-screen .manager-name { grid-column: 2; grid-row: 1; }
|
||||
.library-files-screen .manager-label { white-space: normal; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.5; }
|
||||
.library-files-screen .manager-meta { grid-column: 2; grid-row: 2; flex-wrap: wrap; gap: .3rem .65rem; white-space: normal; padding-left: 1.5rem; }
|
||||
.library-files-screen .manager-more { grid-column: 3; grid-row: 1 / 3; width: 44px; height: 44px; }
|
||||
.library-files-screen .manager-more { color: var(--reader-ink); }
|
||||
.library-files-screen .manager-status { position: static; border: 0; background: transparent; padding: .5rem 1rem 1rem; }
|
||||
.file-manager-header .collection-links { align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: .55rem; }
|
||||
.file-manager-header .collection-link.button { flex: 0 0 auto; min-width: 8.5rem; min-height: 44px; justify-content: flex-start; }
|
||||
.file-manager-header .collection-link.button > span { flex: 1 1 auto; }
|
||||
.library-files-screen .mantine-Menu-dropdown { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; }
|
||||
.library-files-screen .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; }
|
||||
.library-files-screen .mantine-Menu-item:hover, .library-files-screen .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); }
|
||||
.library-files-screen .mantine-Menu-item[data-disabled] { opacity: .45; }
|
||||
.library-files-screen .mantine-Menu-item[data-color='red'] { color: #b23a31; }
|
||||
.library-file-menu { min-width: 10rem; padding: .35rem; border: 1px solid var(--reader-line); border-radius: .75rem; background: var(--reader-paper); color: var(--reader-ink); box-shadow: 0 .75rem 2rem #0003; }
|
||||
.library-file-menu .mantine-Menu-item { min-height: 2.5rem; border-radius: .5rem; color: var(--reader-ink); font: inherit; }
|
||||
.library-file-menu .mantine-Menu-item:hover, .library-file-menu .mantine-Menu-item:focus-visible { background: color-mix(in srgb, var(--reader-accent) 12%, transparent); color: var(--reader-ink); }
|
||||
.library-file-menu .mantine-Menu-item[data-disabled] { opacity: .45; }
|
||||
.library-file-menu .mantine-Menu-item[data-color='red'] { color: #b23a31; }
|
||||
.shelf-settings.panel { position: fixed; }
|
||||
.shelf-settings .panel-header { padding: .75rem 1rem; flex: none; }
|
||||
.shelf-settings .panel-header h2 { flex: 1; }
|
||||
@@ -3918,6 +4016,16 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti
|
||||
.reader-screen .panel-body { padding-top: 0; }
|
||||
.reader-screen .section-title:first-child { margin-top: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sources-header { gap:.6rem; padding-inline:.75rem; }
|
||||
.sources-header h1 { font-size:1.15rem; }
|
||||
.source-entry-top { align-items:flex-start; flex-direction:column; gap:.6rem; }
|
||||
.source-entry-actions, .sources-actions { width:100%; flex-wrap:wrap; }
|
||||
.source-entry-actions .button, .sources-actions .button { flex:1 1 8rem; }
|
||||
.source-interval { width:100%; justify-content:space-between; }
|
||||
.source-interval select { flex:1; }
|
||||
.library-files-screen .manager-actions { overflow-x:auto; }
|
||||
}
|
||||
@keyframes reader-contents-in {
|
||||
from { opacity: 0; transform: translateX(-1rem); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { render } from '../src/shared/ui/render-root.ts';
|
||||
import { MediaLayout } from '../src/app/App.tsx';
|
||||
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.append(root);
|
||||
const runtime = { mediaApi: { preferenceScope: () => 'media-shell-test' } } as any;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => render(null, root));
|
||||
});
|
||||
|
||||
describe('media shell navigation placement', () => {
|
||||
it('keeps channel navigation after the route-owned media page content', () => {
|
||||
act(() => render(
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<MemoryRouter initialEntries={['/media/video']}>
|
||||
<Routes>
|
||||
<Route path="/media/:channel" element={<MediaLayout />}>
|
||||
<Route index element={<main data-testid="media-page">影视页面</main>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</RuntimeContext.Provider>,
|
||||
root,
|
||||
));
|
||||
const page = root.querySelector<HTMLElement>('[data-testid="media-page"]');
|
||||
const channels = root.querySelector<HTMLElement>('nav.media-channel-entry');
|
||||
expect(root.innerHTML).toContain('影视页面');
|
||||
expect(page).not.toBeNull();
|
||||
expect(channels).not.toBeNull();
|
||||
expect(page!.compareDocumentPosition(channels!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(channels!.getAttribute('aria-label')).toBe('内容频道');
|
||||
expect(channels!.querySelector('[aria-current="page"]')?.textContent).toContain('影视');
|
||||
});
|
||||
});
|
||||