feat(web): 我的 opens every row as a page; preferences on one page

我的 mixed sheets (书架显示, 系统设置, OPDS nested in the shelf sheet) with pages
(外观与主题, 影音浏览, 播放), and the personal preferences were four separate rows.

- 偏好设置 (#/me/settings) holds 外观与主题, 书架, 影音浏览 and 播放 as sections of
  one page, with a jump bar and ?section=; every control applies at once (影音浏览
  loses its save button and unsaved-changes prompt). The shelf's gear still opens
  the same fields (ShelfSettingsFields) as a sheet shortcut.
- 系统设置 becomes three admin pages of 我的 › 管理: 用户管理, 注册与邀请, 服务配置
  (#/me/admin/*, lazily loaded).
- 连接外部阅读器 (OPDS) is a page (#/me/opds) under 账号, also reached from the
  书架 section and the shelf sheet; back returns to whoever opened it.
- Old /me/theme|browse|playback and /media/:channel/settings[/:panel] redirect to
  the matching section; /me/account keeps its page.
- Drops MediaSettingsPage and the CSS only it used; docs follow the new paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 15:12:40 +00:00
1 parent 6b58ffd93c
commit b8e5102669
38 files changed
+547 -335

No files matched your search

+2 -2
View File
@@ -493,7 +493,7 @@ pdf / 单图没有独立目录,回落到 items。来源章节出版物(`chap
可写书库中的目录项;只读挂载时所有这些写操作统一返回 `403 READ_ONLY_MOUNT`。
**成员只能新增书籍。** 管理员的上传不受限制。成员能否上传由业务设置 `library.memberUpload`
(「我的 → 系统设置 → 服务配置 → 书库 → 允许成员上传书籍」,默认开启)决定;关闭时成员的
(「我的 → 服务配置 → 书库 → 允许成员上传书籍」,默认开启)决定;关闭时成员的
`POST` 在读取请求体之前就返回 `403 UPLOAD_DISABLED`。开启时,成员(`role=member`)的上传:
- 只保存扫描器能识别为书籍的文件(扩展名在 `GET /library/formats` 里),其余文件记入
@@ -713,7 +713,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
- 未配置时返回 `400 TTS_DISABLED`;`text` 为空返回 `400 BAD_REQUEST`。上游失败细分为 `TTS_AUTH`(401/403)、
`TTS_TIMEOUT`、`TTS_CONTENT`(非音频)、`TTS_EMPTY`(空音频)和 `TTS_UPSTREAM`(其余),均为 400。
在「系统设置 → 服务配置 → HTTP 朗读」修改以下配置:
在「我的 → 服务配置 → HTTP 朗读」修改以下配置:
| 配置项 | 说明 |
| --- | --- |
+3 -3
View File
@@ -79,7 +79,7 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
2. **内容哈希**:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次(默认值,
管理员可在「系统设置 → 服务配置」里改扫描间隔)也很轻。
管理员可在「我的 → 服务配置」里改扫描间隔)也很轻。
`Scanner.scan()` 对并发调用做去重(共享同一个 Promise),
避免定时器和手动触发同时砸磁盘。
@@ -411,7 +411,7 @@ URL 从头到尾不变。手机上这一点几乎看不见——直到读者按
| `#/search?q=` | 书籍与影音的全局搜索 |
| `#/library`、`#/library/books`、`#/library/files` | 书库浏览、书籍列表、文件管理(仅管理员) |
| `#/sources` 及 `#/sources/...` | 书源中心、浏览、书源编辑、凭据、扩展和插件管理 |
| `#/me`、`#/me/:panel` | 「我的」及其设置面板(主题、浏览、播放、账号等) |
| `#/me`、`#/me/settings`、`#/me/account`、`#/me/opds`、`#/me/admin/*` | 「我的」入口页;偏好设置(主题、书架、影音浏览、播放为同一页的分区);账号;外部阅读器(OPDS);管理员的用户管理、注册与邀请、服务配置。旧 `#/me/:panel` 跳到对应分区 |
| `#/settings` | 兼容旧深链接,重定向到书架 |
| `#/book/:bookId` | 阅读器 |
| `#/media/:channel` | 影音频道目录 |
@@ -734,7 +734,7 @@ Android 上经典的「页面自己往边上飘」bug。
| --- | --- | --- |
| `native` | Android 外壳的 `TextToSpeech` | 有 `speech` 接口的外壳(版本 ≥ 3) |
| `system` | WebView / 浏览器的 `speechSynthesis` | 默认。免费、离线、无往返 |
| `http` | 服务端配置的 HTTP 朗读服务(系统设置 → 服务配置;`TTS_URL` 环境变量只作初始值),经 `/api/v1/tts` 代理 | 前两者都不可用时 |
| `http` | 服务端配置的 HTTP 朗读服务(我的 → 服务配置;`TTS_URL` 环境变量只作初始值),经 `/api/v1/tts` 代理 | 前两者都不可用时 |
`auto` 的优先级是 native → system → http,理由不是「谁更好」而是**谁知道得更多**:
+1 -1
View File
@@ -1,6 +1,6 @@
# 业务配置
管理员在「我的 → 管理 → 系统设置 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
管理员在「我的 → 管理 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
| 分类 | 可修改内容 |
| --- | --- |
+4 -4
View File
@@ -15,7 +15,7 @@ Read-only books do not prevent account or progress updates in the data directory
## Business settings in the UI
Administrators configure AI features, media playback mode, HTTP speech, scanning, scraping, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under Me → System settings → Service configuration (我的 → 系统设置 → 服务配置). Registration is set in the same panel under Registration and invitations (注册与邀请). Values persist in reader.db and apply without restarting.
Administrators configure AI features, media playback mode, HTTP speech, scanning, scraping, login lifetimes, public URL, browser origins, TMDB, MusicBrainz and WebDAV under Me → Service configuration (我的 → 服务配置). Registration is set under Me → Registration and invitations (我的 → 注册与邀请). Values persist in reader.db and apply without restarting.
On first upgrade, legacy business environment variables are imported once. Existing database values, including cleared credentials, always win. See [business settings (中文)](business-settings.zh-CN.md).
@@ -49,7 +49,7 @@ reader.example.com {
}
```
Set the public URL to `https://reader.example.com` in Me → System settings → Service configuration → Login and service access (登录与服务访问). If Caddy also runs in a container, use reader's service address on the container network instead.
Set the public URL to `https://reader.example.com` in Me → Service configuration → Login and service access (登录与服务访问). If Caddy also runs in a container, use reader's service address on the container network instead.
For separately hosted frontends, set allowed browser origins in Login and service access to the Web client's actual origin. Source search uses Streamable HTTP: proxies must forward `text/event-stream` incrementally rather than buffer the entire response. A VPN or tunnel can provide access when you have no public IP.
@@ -57,7 +57,7 @@ For separately hosted frontends, set allowed browser origins in Login and servic
Use Android system speech or browser `speechSynthesis` when available. To enable an HTTP speech service, configure a compatible upstream in the administrator UI:
Configure the synthesis URL, token, voices URL, timeout and cache limit in Me → System settings → Service configuration → HTTP speech (HTTP 朗读). Test the connection and preview audio before saving.
Configure the synthesis URL, token, voices URL, timeout and cache limit in Me → Service configuration → HTTP speech (HTTP 朗读). Test the connection and preview audio before saving.
reader proxies speech requests and does not include a synthesis model. The upstream must implement the expected request protocol; a service's name alone does not establish compatibility. See the [TTS API (中文)](api.md). The address must be reachable from the reader container; `localhost` inside a container refers to that container.
@@ -88,7 +88,7 @@ Mount local movie, series, music, and audiobook directories separately (read-onl
The official image includes ffprobe and ffmpeg. For native runs, put them on PATH or set MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH to executable paths. They probe metadata and extract embedded resources; they do not enable transcoding.
Configure TMDB credentials and MusicBrainz contact information under Me → System settings → Service configuration. Changes apply without restarting. Local tags and NFO work without online credentials.
Configure TMDB credentials and MusicBrainz contact information under Me → Service configuration. Changes apply without restarting. Local tags and NFO work without online credentials.
Configure OpenList in the library creation form; no local mount is needed. Reader must reach OpenList and its download upstreams. Remote embedded metadata is not read, and some drivers require OpenList Web proxy. See [OpenList setup (中文)](media-openlist.md).
+4 -4
View File
@@ -15,7 +15,7 @@
## 页面业务配置
管理员在「我的 → 系统设置 → 服务配置」中管理 AI 功能、媒体播放方式、HTTP 朗读、扫描、刮削、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略在同一面板的「注册与邀请」中设置。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
管理员在「我的 → 服务配置」中管理 AI 功能、媒体播放方式、HTTP 朗读、扫描、刮削、登录有效期、对外地址、浏览器来源、TMDB、MusicBrainz 及 WebDAV 备份目标,注册策略在「我的 → 注册与邀请」中设置。配置写入 reader.db,保存后无需重启。详见[业务配置](business-settings.zh-CN.md)。
升级首次启动会导入旧业务环境变量;数据库存在配置后不再读取这些环境变量,清空字段也不会恢复旧值。
@@ -49,13 +49,13 @@ reader.example.com {
}
```
在「我的 → 系统设置 → 服务配置 → 登录与服务访问」中设置对外服务地址为 `https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
在「我的 → 服务配置 → 登录与服务访问」中设置对外服务地址为 `https://reader.example.com`。如果 Caddy 也在容器中,代理地址应改为容器网络中 reader 的服务地址。
前后端分开部署时,在「登录与服务访问」中设置允许的浏览器来源为 Web 客户端的实际来源。书源搜索采用 Streamable HTTP,代理需要持续转发 `text/event-stream`;不要把搜索响应缓冲到完成后才返回。没有公网 IP 时也可用 VPN 或隧道连接服务。
## HTTP 朗读
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,进入「我的 → 系统设置 → 服务配置 → HTTP 朗读」,填写合成接口、认证令牌和可选音色列表地址,启用后检测连接、试听并保存。请求超时和音频缓存上限也在此设置。
优先使用 Android 系统语音或浏览器 `speechSynthesis`。需要 HTTP 语音时,进入「我的 → 服务配置 → HTTP 朗读」,填写合成接口、认证令牌和可选音色列表地址,启用后检测连接、试听并保存。请求超时和音频缓存上限也在此设置。
reader 代理语音请求,不内置合成模型。上游接口需兼容项目的请求协议;不能仅凭服务名称判断是否兼容,详见 [TTS API](api.md)。地址必须从 reader 容器内可达,容器内的 `localhost` 指容器自身。
@@ -86,7 +86,7 @@ reader 代理语音请求,不内置合成模型。上游接口需兼容项目
官方镜像包含 ffprobe 和 ffmpeg。本机启动时将二者加入 PATH,或设置 MEDIA_FFPROBE_PATH / MEDIA_FFMPEG_PATH 为可执行文件路径。ffprobe 用于时长、编码、标签和章节,ffmpeg 用于内嵌资源提取,均不提供转码。
在线匹配在「我的 → 系统设置 → 服务配置」的 TMDB 和 MusicBrainz 分类中配置令牌、API Key、应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
在线匹配在「我的 → 服务配置」的 TMDB 和 MusicBrainz 分类中配置令牌、API Key、应用标识与联系地址。可测试连接、调整资料语言并保存,无需重启。本地 NFO 和标签读取不依赖在线凭据。
OpenList 连接信息在建库界面设置,无需本地挂载;服务端必须能访问 OpenList 和其下载源。远端内嵌资料暂不读取,某些驱动需要 OpenList Web 代理。配置与限制见 [OpenList 接入](media-openlist.md)。
+1 -1
View File
@@ -63,7 +63,7 @@ web/src/
保留的兼容入口是有意的:
- `/settings` 重定向到 `/shelf`,兼容旧书架设置深链接;用户设置在「我的」(`/me`、`/me/:panel`);
- `/settings` 重定向到 `/shelf`,兼容旧书架设置深链接;用户设置在「我的」(`/me`;全部个人偏好在 `/me/settings` 一页,旧 `/me/:panel` 跳到对应分区);
- 影音设置页已移除:`/media/:channel/settings` 与 `/media/:channel/settings/:panel` 重定向到「我的」,媒体库管理(`.../settings/libraries`)和扫描与刮削(`.../settings/tasks`)保留;
- `/sources/search` 仍打开书源中心;
- `/sources/:sourceId/:pageId` 与新的 `/sources/:sourceId/pages/:pageId` 都可打开扩展页;
+3 -3
View File
@@ -41,7 +41,7 @@ For example, use `/srv/books:/books:ro` on Linux, or `"D:/books:/books:ro"` with
### Choose read-only or writable books
- **Browse and read only**: keep `:ro`. Add books through the host or NAS and let reader scan them.
- **Manage files in the browser**: use `:rw` and give the server process write permission. Administrators can upload, rename, move, delete, and create directories in the library file manager; members can only upload book files from the library page (an administrator can turn this off under Me → System settings → Service configuration → 书库).
- **Manage files in the browser**: use `:rw` and give the server process write permission. Administrators can upload, rename, move, delete, and create directories in the library file manager; members can only upload book files from the library page (an administrator can turn this off under Me → Service configuration → 书库).
A read-only book mount still allows shelves, progress, and notes to be saved in `/data`. See [configuration](configuration.en.md) for permissions and mount details.
@@ -59,7 +59,7 @@ Open `http://localhost:5888`, or `http://<server-ip>:5888` from another device.
2. Wait for the initial scan, then open a book from the shelf or library.
3. Create accounts for other members. Public registration is disabled by default after the first account.
To allow later self-registration, an administrator switches Me → System settings → Registration and invitations (我的 → 系统设置 → 注册与邀请) to open or invite-only registration. `ALLOW_REGISTRATION: "true"` in Compose only seeds the registration policy on the first start; afterwards the setting in the UI wins.
To allow later self-registration, an administrator switches Me → Registration and invitations (我的 → 注册与邀请) to open or invite-only registration. `ALLOW_REGISTRATION: "true"` in Compose only seeds the registration policy on the first start; afterwards the setting in the UI wins.
The health endpoint is `/api/v1/health`. For startup failures, missing books, or write errors, see [troubleshooting](configuration.en.md#troubleshooting).
@@ -90,7 +90,7 @@ On Windows, the Compose setup above avoids shell-specific path and line-continua
### Local books
Place EPUB, TXT, PDF, CBZ/ZIP, or comic image directories in your mounted library. By default, the server checks the library for changes every 60 seconds; administrators can change the interval (0 disables it) under Me → System settings → Service configuration → Scan settings (扫描设置). The server also scans once at startup. With a writable mount, administrators upload from the file manager and members use 上传书籍 (Upload books) at the top of the library page (book formats only; no unpacking, no overwriting).
Place EPUB, TXT, PDF, CBZ/ZIP, or comic image directories in your mounted library. By default, the server checks the library for changes every 60 seconds; administrators can change the interval (0 disables it) under Me → Service configuration → Scan settings (扫描设置). The server also scans once at startup. With a writable mount, administrators upload from the file manager and members use 上传书籍 (Upload books) at the top of the library page (book formats only; no unpacking, no overwriting).
The shelf contains readable books; the library lets you browse the disk directory. Removing a book from a shelf is different from deleting its file. See the [user guide (中文)](user-guide.zh-CN.md).
+3 -3
View File
@@ -41,7 +41,7 @@ volumes:
### 选择只读或可写书库
- **只浏览和阅读**:保留 `:ro`。通过宿主机或 NAS 放入新书,等待扫描。
- **在网页管理文件**:改为 `:rw`,并确保服务进程对该目录有写权限。管理员可以在书库文件管理页上传、改名、移动、删除和新建目录;普通成员只能在书库页上传书籍文件(可在「我的 → 系统设置 → 服务配置 → 书库」关闭)。
- **在网页管理文件**:改为 `:rw`,并确保服务进程对该目录有写权限。管理员可以在书库文件管理页上传、改名、移动、删除和新建目录;普通成员只能在书库页上传书籍文件(可在「我的 → 服务配置 → 书库」关闭)。
只读书库不会妨碍进度、笔记或书架保存,这些数据写入 `/data`。挂载和权限的详细说明见[部署配置](configuration.zh-CN.md)。
@@ -59,7 +59,7 @@ docker compose logs --tail=100 reader
2. 等待初次扫描,在书架或书库中打开一本书。
3. 为家人或其他成员创建账号。首个账号创建后默认关闭公开注册。
如需开放后续注册,由管理员在「我的 → 系统设置 → 注册与邀请」中改为开放注册或仅邀请码注册。Compose 中的 `ALLOW_REGISTRATION: "true"` 只在首次启动初始化注册策略时生效,之后以页面设置为准。
如需开放后续注册,由管理员在「我的 → 注册与邀请」中改为开放注册或仅邀请码注册。Compose 中的 `ALLOW_REGISTRATION: "true"` 只在首次启动初始化注册策略时生效,之后以页面设置为准。
健康检查地址为 `/api/v1/health`。若启动失败、目录为空或无法写入,请看[故障排查](configuration.zh-CN.md#故障排查)。
@@ -90,7 +90,7 @@ Windows 推荐使用上面的 Compose 配置,避免不同 shell 的路径和
### 本地书籍
把 EPUB、TXT、PDF、CBZ/ZIP 或漫画图片目录放到挂载的书库中。默认每 60 秒自动检查书库变化,间隔可在「我的 → 系统设置 → 服务配置 → 扫描设置」调整(0 关闭);服务启动时也会扫描一次。可写挂载下,管理员在文件管理页上传,普通成员在书库页头部的「上传书籍」上传(只收书籍格式,不解包、不覆盖)。
把 EPUB、TXT、PDF、CBZ/ZIP 或漫画图片目录放到挂载的书库中。默认每 60 秒自动检查书库变化,间隔可在「我的 → 服务配置 → 扫描设置」调整(0 关闭);服务启动时也会扫描一次。可写挂载下,管理员在文件管理页上传,普通成员在书库页头部的「上传书籍」上传(只收书籍格式,不解包、不覆盖)。
书架显示可阅读的书籍;书库按磁盘目录浏览。从书架移除书籍与删除磁盘文件是两种不同操作。详见[使用手册](user-guide.zh-CN.md)。
+2 -2
View File
@@ -22,7 +22,7 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真
| 收藏 | #/media/favorites,scope 和 offset 保存筛选、分页;#/media/:channel/favorites 预选该频道 |
| 搜索 | #/media/search?q=…&scope=… |
| 历史、待播队列 | #/media/:channel/history;#/media/queue、#/media/:channel/queue |
| 偏好设置(我的) | #/me/theme、#/me/browse、#/me/playback、#/me/account |
| 偏好设置(我的) | #/me/settings(外观与主题、书架、影音浏览、播放四个分区,`?section=` 定位);#/me/account;#/me/opds |
| 媒体库管理 | #/media/:channel/settings/libraries |
| 新建、编辑、权限 | #/media/:channel/settings/libraries/new、#/media/:channel/settings/libraries/:libraryId/edit、#/media/:channel/settings/libraries/:libraryId/permissions |
| 扫描与刮削 | #/media/:channel/settings/tasks?tab=scan\|ai\|scrape&library=… |
@@ -34,6 +34,6 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真
| 文件夹、文件 | #/media/:channel/folders/:libraryId?path=…&offset=…(libraryId 可省略);文件继续使用该路由并追加 asset=… |
| 演播者、演播作品 | #/media/audiobook/narrators/:narrator,以及 /media/audiobook/narrators/:narrator/works/:workId |
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;已移除的影音设置 `/media/:channel/settings` 及其 `/:panel` 子页跳转到 `/me` 或对应的 `/me/:panel`;未知路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
路由表之外保留少量旧深链接兼容:`/media`、`/media/music` 和 `/media/audiobook` 会进入对应频道默认页;已移除的影音设置 `/media/:channel/settings` 及其 `/:panel` 子页跳转到 `/me/settings` 的对应分区(旧 `/me/theme`、`/me/browse`、`/me/playback` 同样跳转);未知路径回到 `/media/video`。书源与系统旧入口的兼容规则见[React 架构说明](frontend-react-migration.md)。返回按实际浏览器历史和保存的来源状态处理;不为每个页面生成 return 查询参数。播放器的 item、part 查询参数保存作品及播放部分,可刷新后重新校验授权并恢复页面。
协议实现以[React 页面路由](../web/src/app/router/routes.tsx)及[影音 feature](../web/src/features/media/)为准。冻结 v2 原型仍使用已移除的 `/media/:channel/settings/*` 设置页路径,作品 ID、查询参数和状态参数只用于设计演示,不作为路由依据。
+1 -1
View File
@@ -18,7 +18,7 @@
- 按页递归读取目录,支持电影、剧集、音乐、有声书的既有文件扩展名与文件名分组规则。
- 读取外置 NFO、封面、歌手资料、LRC 歌词及字幕。旁车匹配保留列表返回的实际大小写路径。
- 默认自动模式:播放票据鉴权后,Reader 解析临时下载地址并以 307 跳转让浏览器直连。管理 Token 和目录密码不下发;临时下载地址对浏览器可见。同一会话复用解析结果最多 30 秒,每次跳转仍鉴权。可在「系统设置 → 服务配置 → 媒体播放」选择自动、直连或代理,详见[业务配置](business-settings.zh-CN.md#openlist-播放)。
- 默认自动模式:播放票据鉴权后,Reader 解析临时下载地址并以 307 跳转让浏览器直连。管理 Token 和目录密码不下发;临时下载地址对浏览器可见。同一会话复用解析结果最多 30 秒,每次跳转仍鉴权。可在「我的 → 服务配置 → 媒体播放」选择自动、直连或代理,详见[业务配置](business-settings.zh-CN.md#openlist-播放)。
- 目录读取或旁车网络读取失败时,扫描失败并保留之前的作品、可用状态和元数据,不将断网误判为资源全部失效。
- 同一次扫描复用目录快照,最多缓存 64 个目录、合计 20,000 项,并合并正在进行的相同目录请求;失败不缓存,下一次扫描创建新适配器重新读取。
- 编辑凭据先校验新连接;失败保留旧凭据。新增、编辑和扫描仅管理员可操作。
+2 -2
View File
@@ -13,7 +13,7 @@
扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。
媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 系统设置 → 服务配置」的“扫描设置”中:
媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 服务配置」的“扫描设置”中:
- 自动扫描新文件(默认开):每小时检查一次所有媒体库,未变化的文件沿用上次的技术信息。
- 刮削封面与简介(默认关):扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料、也从未刮削过的作品发起一次批量刮削;未匹配的作品不会每次重试。
@@ -75,7 +75,7 @@
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
管理员打开「我的 → 系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
管理员打开「我的 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
+4 -4
View File
@@ -2,11 +2,11 @@
## 在其他阅读器中打开 Reader 书架
1. 登录 Reader,打开“书架设置 → 连接外部阅读器”(书架页右上角的书架设置,或「我的 → 书架显示」)。
1. 登录 Reader,打开「我的 → 外部阅读器」(也可从书架页右上角的书架设置或「我的 → 偏好设置 → 书架」点“连接外部阅读器”)。
2. 填写客户端名称并创建 OPDS 凭据。复制目录地址、OPDS 用户名和密码到支持 OPDS 1.2 / HTTP Basic 的阅读器。
3. 密码只显示一次,有效期一年。丢失后撤销并创建新的;不使用 Reader 登录密码。
目录地址通常为 `https://你的域名/opds`。反代含路径前缀时,在「我的 → 系统设置 → 服务配置 → 登录与服务访问」将对外服务地址设为完整外部地址(旧 `PUBLIC_URL` 环境变量只在首次初始化时导入),并确保反代转发 OPDS 路由与 Authorization 头。对外部署须使用 HTTPS。
目录地址通常为 `https://你的域名/opds`。反代含路径前缀时,在「我的 → 服务配置 → 登录与服务访问」将对外服务地址设为完整外部地址(旧 `PUBLIC_URL` 环境变量只在首次初始化时导入),并确保反代转发 OPDS 路由与 Authorization 头。对外部署须使用 HTTPS。
首版提供当前账号未隐藏书架中的文件书籍、搜索、每页 50 本、封面与支持 Range 的原文件下载。章节书和目录漫画暂不发布。目录和下载请求都重新验证客户端凭据及账号状态,并设为 private/no-store。撤销或禁用账号后,新请求立即失效;已经下载到外部设备的文件无法远程删除。
@@ -24,12 +24,12 @@
## 上传一致性备份到 WebDAV
管理员可从「我的 → 系统设置 → 服务配置 → WebDAV 备份」或 CLI 上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
管理员可从「我的 → 服务配置 → WebDAV 备份」或 CLI 上传已完成的备份目录;不直接同步运行中的 DATA_DIR,也不是 Legado 进度互通。先按 [备份指南](backup.zh-CN.md) 停止全部相关服务进程、创建并验证备份。上传阶段可重新启动 Reader,备份目录保持不变。
在编译后的服务端目录运行:
```sh
# 先在系统设置中保存 WebDAV 目标、账号和密码;DATA_DIR 指向运行实例的数据目录
# 先在「我的 → 服务配置 → WebDAV 备份」中保存 WebDAV 目标、账号和密码;DATA_DIR 指向运行实例的数据目录
node dist/maintenance/webdav-cli.js /backups/reader-20260924 reader-20260924.zip
```
+1
View File
@@ -858,6 +858,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 主题在应用壳层统一应用(`ThemeController` 写 `<html data-theme data-mode>`)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--reader-*` 是 `--color-*` 的别名(`theme-tokens.css`),影音页的 `--text`、`--media-*` 别名在 `presentation.css` 顶部。
- 频道色(阅读、影视、音乐、有声书)是每个主题自己的 `--color-ch-*`(填充)、`-soft`(卡片底)和 `-text`(文字),由 `theme-tokens.test.ts` 守住对比度。
- 首页:「继续」一行把在读的书、未看完的影视和有声书画成同一种卡片(封面+作品名+集/章+进度,单集显示所属剧集名),最多两本书,按最近时间排序;然后是书架一行,最后是「看点什么」「听本书」「听张专辑」三行影音,各自可在「最近添加」(`browse?sort=added`,按作品及其分集/曲目最新文件的修改时间)与「随便看看」(`sort=random`,可换一批)之间切换,选择记在本机;有内容才出现。我的页只做入口,不拥有任何设置。
- 我的页的每一行都打开一个页面,不在原地弹 sheet(唯一例外是退出登录的确认)。个人偏好只有一行「偏好设置」(`#/me/settings`):外观与主题、书架、影音浏览、播放是同一页的四个分区,顶部可跳转,`?section=` 定位,所有控件改动即时生效,不设保存按钮。书架页齿轮弹出的书架设置是这一分区的快捷入口,两处共用 `ShelfSettingsFields`。管理员的用户管理、注册与邀请、服务配置各是一页(`#/me/admin/*`),与媒体库、扫描与刮削并列;外部阅读器(OPDS)在账号分组(`#/me/opds`)。
- “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 `web/src/shared/theme/theme.ts` 的 `readThemePreference` / `saveThemePreference`;判断当前主题一律用 `resolveAppTheme` / `resolveReadingTheme`,React 里用 `useThemeState`,不要自己读存储。
- 搜索:桌面侧栏的“搜索书籍和影音”打开搜索页 `#/search?q=`(页内输入框写“搜索书名、片名、歌曲、有声书”),同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
- 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(`?status=`、`?series=`、`?tag=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
+3 -3
View File
@@ -9,7 +9,7 @@
- **书架**是当前账号的阅读列表,可筛选、排序、调整密度并查看阅读进度。顶部可按「全部 / 在读 / 想读 / 已读」筛选(手机上可左右滑动切换),「分组」按系列或标签筛选。每本书的「⋯」菜单可标记想读、在读、已读,清除标记或从书架拿掉;未手动标记时按进度判断在读或已读。
- **书库**按挂载目录浏览本地书籍,可将书加入书架。
- **文件管理**仅供管理员检查磁盘目录。挂载可写且目录权限允许时,可上传、改名、移动、删除和新建目录。
- **上传书籍**:普通成员没有文件管理;管理员在「我的 → 系统设置 → 服务配置 → 书库」开启「允许成员上传书籍」(默认开启)时,成员可在书库浏览页头部点「上传书籍」,把书传到当前文件夹。成员只能新增书籍文件:不支持的格式会被跳过,`.zip/.cbz` 按漫画压缩包原样保存不解包,重名时保留两份,不能覆盖已有文件。
- **上传书籍**:普通成员没有文件管理;管理员在「我的 → 服务配置 → 书库」开启「允许成员上传书籍」(默认开启)时,成员可在书库浏览页头部点「上传书籍」,把书传到当前文件夹。成员只能新增书籍文件:不支持的格式会被跳过,`.zip/.cbz` 按漫画压缩包原样保存不解包,重名时保留两份,不能覆盖已有文件。
从书架移除只改变该账号的书架记录,不删除书文件。文件管理中的删除会修改磁盘,请按界面确认提示操作。书籍的改名或移动按已有身份更新路径,保留阅读记录。
@@ -29,7 +29,7 @@
## 扫描与元数据
默认每 60 秒自动检查书库变化,服务启动时也扫描一次;管理员可在「我的 → 系统设置 → 服务配置 → 扫描设置」调整间隔(0 关闭)。扫描先比较大小和修改时间,再按需计算内容哈希。`.git`、`@eaDir`、`#recycle` 等目录被跳过,符号链接不跟随。
默认每 60 秒自动检查书库变化,服务启动时也扫描一次;管理员可在「我的 → 服务配置 → 扫描设置」调整间隔(0 关闭)。扫描先比较大小和修改时间,再按需计算内容哈希。`.git`、`@eaDir`、`#recycle` 等目录被跳过,符号链接不跟随。
图片目录会结合图片、卷目录和压缩包结构识别。目录中包含其他书籍文件时,不应将这些书吞并为一个漫画目录;目录识别和分页资源规则见[架构](architecture.md)及 [API](api.md)。
@@ -76,7 +76,7 @@ EPUB 章节按需加载,长书的目录与传输窗口分开处理,跨窗口
## 影音入口
打开站点默认进入「首页」,可继续最近的阅读与播放。底部(宽屏为左侧)导航有首页、书架、影音、我的四个标签;宽屏侧栏的搜索框可同时搜索书籍和影音。「影音」内通过频道切换影视、音乐和有声书,手机上可左右滑动切换分类。阅读与影音共用「我的 → 外观与主题」中的主题;影音浏览密度与播放偏好在「我的 → 影音浏览」「我的 → 播放」中设置。
打开站点默认进入「首页」,可继续最近的阅读与播放。底部(宽屏为左侧)导航有首页、书架、影音、我的四个标签;宽屏侧栏的搜索框可同时搜索书籍和影音。「影音」内通过频道切换影视、音乐和有声书,手机上可左右滑动切换分类。个人偏好都在「我的 → 偏好设置」一页:外观与主题(阅读与影音共用)、书架、影音浏览和播放,改动即时生效。
管理员可添加本地目录或 OpenList 媒体库,配置权限、扫描与刮削;用户浏览已授权的内容并播放。建库、歌词、字幕、队列、倍速及睡眠定时见[影音使用说明](media-user-guide.zh-CN.md)。
+7
View File
@@ -24,6 +24,7 @@ const sourceEditor = () => import('../../features/sources/pages/SourceEditorPage
const sourceCredentials = () => import('../../features/sources/pages/SourceCredentialsPage.tsx');
const stats = () => import('../../features/stats/pages/StatsPage.tsx');
const sourceExtension = () => import('../../features/sources/pages/SourceExtensionPage.tsx');
const admin = () => import('../../features/account/pages/AdminPages.tsx');
const Reader = deferred(reader, 'ReaderPage');
const Libraries = deferred(libraries, 'MediaLibrariesPage');
@@ -40,6 +41,9 @@ const SourceEditor = deferred(sourceEditor, 'SourceEditorPage');
const SourceCredentials = deferred(sourceCredentials, 'SourceCredentialsPage');
const SourceExtension = deferred(sourceExtension, 'SourceExtensionPage');
const Stats = deferred(stats, 'StatsPage');
const AdminUsers = deferred(admin, 'AdminUsersPage');
const AdminRegistration = deferred(admin, 'AdminRegistrationPage');
const AdminServices = deferred(admin, 'AdminServicesPage');
export function ReaderPage() { return <Suspense fallback={fallback}><Reader /></Suspense>; }
export function MediaLibrariesPage() { return <Suspense fallback={fallback}><Libraries /></Suspense>; }
@@ -56,3 +60,6 @@ export function SourceEditorPage() { return <Suspense fallback={fallback}><Sourc
export function SourceCredentialsPage() { return <Suspense fallback={fallback}><SourceCredentials /></Suspense>; }
export function StatsPage() { return <Suspense fallback={fallback}><Stats /></Suspense>; }
export function SourceExtensionPage() { return <Suspense fallback={fallback}><SourceExtension /></Suspense>; }
export function AdminUsersPage() { return <Suspense fallback={fallback}><AdminUsers /></Suspense>; }
export function AdminRegistrationPage() { return <Suspense fallback={fallback}><AdminRegistration /></Suspense>; }
export function AdminServicesPage() { return <Suspense fallback={fallback}><AdminServices /></Suspense>; }
+15 -7
View File
@@ -6,13 +6,14 @@ import { MediaFavoritesPage } from '../../features/media/pages/MediaFavoritesPag
import { MediaHistoryPage } from '../../features/media/pages/MediaHistoryPage.tsx';
import { MediaDetailPage } from '../../features/media/pages/MediaDetailPage.tsx';
import { MediaQueuePage } from '../../features/media/pages/MediaQueuePage.tsx';
import { LegacyMediaSettingsRedirect, MediaSettingsPage } from '../../features/media/pages/MediaSettingsPage.tsx';
import { MediaPlayerPage } from '../../features/media/pages/MediaPlayerPage.tsx';
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 { MinePage } from '../../features/account/pages/MinePage.tsx';
import { LegacySettingsRedirect, SettingsPage } from '../../features/account/pages/SettingsPage.tsx';
import { AccountPage, OpdsPage } from '../../features/account/pages/AccountPages.tsx';
import { HomePage } from '../../features/home/pages/HomePage.tsx';
import { SearchPage } from '../../features/search/pages/SearchPage.tsx';
import { MediaFoldersPage } from '../../features/media/pages/MediaFoldersPage.tsx';
@@ -22,6 +23,7 @@ import {
MediaLibrariesPage, MediaLibraryCreatePage, MediaLibraryEditPage, MediaLibraryPermissionsPage, MediaTasksPage,
MediaMetadataEditPage, MediaMatchPage, MediaChaptersPage, MediaEditionPage, MediaStructurePage,
SourceEditorPage, SourceCredentialsPage, SourceExtensionPage, StatsPage,
AdminUsersPage, AdminRegistrationPage, AdminServicesPage,
} from './lazy-pages.tsx';
import { SourceCatalogPage } from '../../features/sources/pages/SourceCatalogPage.tsx';
@@ -54,13 +56,19 @@ export const routes = [{
{ path: 'settings', element: <Navigate to="/shelf" replace /> },
{ path: 'book/:bookId', element: <ReaderPage /> },
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
// Its settings rows open /me/:panel (theme, browse, playback, account),
// so back from any of them returns to 我的.
// Every row of it opens a page under /me, so back from any of them returns to 我的:
// all personal preferences are sections of one page (/me/settings), administration
// is one page per job (/me/admin/*). Old panel links (/me/theme, …) redirect.
{ path: 'me', element: <MediaLayout />, children: [
{ index: true, element: <MinePage /> },
// Before :panel, which would otherwise take it as a settings panel name.
{ path: 'settings', element: <SettingsPage /> },
{ path: 'stats', element: <StatsPage /> },
{ path: ':panel', element: <MediaSettingsPage /> },
{ path: 'account', element: <AccountPage /> },
{ path: 'opds', element: <OpdsPage /> },
{ path: 'admin/users', element: <AdminUsersPage /> },
{ path: 'admin/registration', element: <AdminRegistrationPage /> },
{ path: 'admin/services', element: <AdminServicesPage /> },
{ path: ':panel', element: <LegacySettingsRedirect /> },
] },
{ path: 'media', element: <MediaLayout />, children: [
{ index: true, element: <Navigate to="video" replace /> },
@@ -72,13 +80,13 @@ export const routes = [{
{ path: ':channel/favorites', element: <MediaFavoritesPage /> },
{ path: ':channel/queue', element: <MediaQueuePage /> },
// 影音设置 is gone; its panels live in 我的.
{ path: ':channel/settings', element: <LegacyMediaSettingsRedirect /> },
{ path: ':channel/settings', element: <LegacySettingsRedirect /> },
{ path: ':channel/settings/libraries', element: <MediaLibrariesPage /> },
{ path: ':channel/settings/libraries/new', element: <MediaLibraryCreatePage /> },
{ path: ':channel/settings/libraries/:libraryId/edit', element: <MediaLibraryEditPage /> },
{ path: ':channel/settings/libraries/:libraryId/permissions', element: <MediaLibraryPermissionsPage /> },
{ path: ':channel/settings/tasks', element: <MediaTasksPage /> },
{ path: ':channel/settings/:panel', element: <LegacyMediaSettingsRedirect /> },
{ path: ':channel/settings/:panel', element: <LegacySettingsRedirect /> },
{ path: ':channel/folders/:libraryId?', element: <MediaFoldersPage /> },
{ path: 'audiobook/narrators', element: <MediaNarratorsPage /> },
{ path: 'audiobook/narrators/:narrator/works/:workId', element: <MediaNarratorsPage /> },
-1
View File
@@ -151,7 +151,6 @@
body[data-app-nav] .media-screen .media-mobile-title,
body[data-app-nav] .media-screen.media-secondary-page > .media-heading,
body[data-app-nav] .media-narrators:not([data-view=directory]) > .media-narrator-heading,
body[data-app-nav] .media-utility-workspace .media-settings-heading,
body[data-app-nav] .media-manager-workspace > .media-heading { left: calc(var(--app-nav-width) + 24px); right: 32px; }
body[data-app-nav] .media-screen.media-detail-shell > .media-heading { left: var(--app-nav-width); right: 0; }
}
@@ -0,0 +1,27 @@
import { Navigate, useLocation } from 'react-router-dom';
import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
import { MediaAccountPanel } from '../../media/components/settings.tsx';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { OpdsAccess } from '../../../ui/opds-access.tsx';
/** 账号与连接 (/me/account): who is signed in, and to which server. */
export function AccountPage() {
const account = useRuntime().mediaApi.accountInfo();
if (!account) return <Navigate to="/me" replace />;
return <MediaPageFrame className="media-secondary-page" title="账号与连接" backTo="/me" backLabel="返回我的">
<MediaAccountPanel account={account} />
</MediaPageFrame>;
}
/**
* 外部阅读器 (/me/opds). Reached from 我的, from 偏好设置 › 书架 and from the shelf's
* settings sheet; back returns to whichever of them opened it (`state.returnTo`).
*/
export function OpdsPage() {
const runtime = useRuntime();
const returnTo = useLocation().state?.returnTo;
const back = typeof returnTo === 'string' && returnTo.startsWith('/') ? returnTo : '/me';
return <MediaPageFrame className="media-secondary-page opds-page" title="连接外部阅读器" backTo={back} backLabel={back === '/me' ? '返回我的' : '返回上一页'}>
<OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} />
</MediaPageFrame>;
}
@@ -0,0 +1,33 @@
import { Navigate } from 'react-router-dom';
import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { RegistrationManagement, UserManagement } from '../../../ui/system-settings.tsx';
import { BusinessSettings } from '../../../ui/business-settings.tsx';
import type { ReactNode } from 'react';
/**
* 我的 › 管理: one page per job (用户管理, 注册与邀请, 服务配置), like 媒体库 and
* 扫描与刮削 beside them. Lazily loaded (app/router/lazy-pages.tsx). A member who
* follows an old link lands back on 我的; the server checks the role regardless.
*/
function AdminFrame({ title, children }: { title: string; children: ReactNode }) {
const role = useAuthStore(state => (state.verifiedUser ?? state.session?.user)?.role);
if (role !== 'admin') return <Navigate to="/me" replace />;
return <MediaPageFrame className="media-secondary-page admin-page" title={title} backTo="/me" backLabel="返回我的">{children}</MediaPageFrame>;
}
export function AdminUsersPage() {
const { api } = useRuntime();
return <AdminFrame title="用户管理"><UserManagement api={api} /></AdminFrame>;
}
export function AdminRegistrationPage() {
const { api } = useRuntime();
return <AdminFrame title="注册与邀请"><RegistrationManagement api={api} /></AdminFrame>;
}
export function AdminServicesPage() {
const { api } = useRuntime();
return <AdminFrame title="服务配置"><div className="user-management"><BusinessSettings api={api} /></div></AdminFrame>;
}
+17 -27
View File
@@ -3,13 +3,8 @@ import { Link } from 'react-router-dom';
import { useState, type ReactNode } from 'react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { themeSummary } from '../../../shared/theme/theme.ts';
import { useThemeState } from '../../../shared/theme/use-theme.ts';
import { readMediaPreferences } from '../../media/services/preferences.ts';
import { DENSITY_LABELS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
import { OpdsAccess } from '../../../ui/opds-access.tsx';
import { SystemSettings } from '../../../ui/system-settings.tsx';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { useSyncStore } from '../../../shared/stores/sync.store.ts';
import { connectionLabel } from '../../../app/AppNav.tsx';
@@ -38,23 +33,22 @@ function serverHost(baseUrl: string): string {
}
/**
* 我的: the settings hub. Every setting in the app is reachable from here, grouped by
* what it is about rather than by which page happens to own it:
* - 偏好: how things look and behave for *you* (theme, the shelf, media browsing, playback);
* - 管理: what an administrator changes for everyone (users, services, media libraries);
* - 账号: who you are on this server.
* Each row opens the one place that setting lives; the shelf sheet is a shortcut
* to a subset of this list, not a separate setting.
* 我的: the hub. Every row opens a page under /me (or the page it names), never a
* sheet, so the whole screen behaves one way:
* - 内容: where your things are (书库, 书源, statistics, media libraries);
* - 偏好: one row, 偏好设置 — theme, the shelf, media browsing and playback are
* sections of a single page, so there is one place to look for any of them;
* - 管理: what an administrator changes for everyone, one page per job;
* - 账号: who you are on this server and which outside readers may see your shelf.
* Signing out is the one exception: a confirmation, not a place.
*/
export function MinePage() {
const runtime = useRuntime();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const settings = useSettingsStore(state => state.settings);
const admin = user?.role === 'admin';
const scope = runtime.mediaApi.preferenceScope();
const themeName = themeSummary(useThemeState(scope).preference);
const mediaDensity = readMediaPreferences(scope).density === 'compact' ? '紧凑' : '宽松';
const [sheet, setSheet] = useState<'shelf' | 'opds' | 'system' | 'sign-out' | null>(null);
const [signOutOpen, setSignOutOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const name = user?.displayName || user?.username || '';
// The same sync-engine verdict the sidebar shows; on a phone this page is the only place it appears.
@@ -80,31 +74,27 @@ export function MinePage() {
{admin && <Row icon="video" label="媒体库" to="/media/video/settings/libraries" state={{ returnTo: '/me' }} />}
</Group>
{admin && <Group title="管理" order={3}>
<Row icon="settings" label="系统设置" value="用户、注册、服务配置" onClick={() => setSheet('system')} />
<Row icon="users" label="用户管理" to="/me/admin/users" />
<Row icon="invite" label="注册与邀请" to="/me/admin/registration" />
<Row icon="server" label="服务配置" value="朗读、AI、刮削、备份" to="/me/admin/services" />
<Row icon="refresh" label="扫描与刮削" to="/media/video/settings/tasks" state={{ returnTo: '/me' }} />
</Group>}
</div>
<div className="mine-column">
<Group title="偏好" order={2}>
<Row icon="palette" label="外观与主题" value={themeName} to="/me/theme" />
<Row icon="grid" label="书架显示" value={DENSITY_LABELS[settings.shelfDensity]} onClick={() => setSheet('shelf')} />
<Row icon="sliders" label="影音浏览" value={mediaDensity} to="/me/browse" />
<Row icon="play-circle" label="播放" to="/me/playback" />
<Row icon="sliders" label="偏好设置" value={themeName} to="/me/settings" />
</Group>
<Group title="账号" order={4}>
<Row icon="account" label="账号与连接" to="/me/account" />
<Row icon="logout" label="退出登录" danger onClick={() => setSheet('sign-out')} />
<Row icon="connect" label="外部阅读器" value="OPDS" to="/me/opds" />
<Row icon="logout" label="退出登录" danger onClick={() => setSignOutOpen(true)} />
</Group>
</div>
</div>
</div>
{/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */}
{sheet === 'shelf' && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />}
{sheet === 'opds' && <OpdsAccess api={runtime.api} onSignedOut={() => { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />}
{sheet === 'system' && <SystemSettings api={runtime.api} onClose={() => setSheet(null)} />}
{sheet === 'sign-out' && <ConfirmSheet danger title="退出登录" busy={signingOut} confirmText={signingOut ? '正在退出…' : '退出登录'}
{signOutOpen && <ConfirmSheet danger title="退出登录" busy={signingOut} confirmText={signingOut ? '正在退出…' : '退出登录'}
text={`将退出 ${serverHost(runtime.api.baseUrl)} 上的账号「${name}」,之后需要重新登录才能继续使用。`}
onCancel={() => setSheet(null)}
onCancel={() => setSignOutOpen(false)}
onConfirm={() => { setSigningOut(true); void runtime.api.signOut().finally(() => setSigningOut(false)); }} />}
</>;
}
@@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react';
import { Link, Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { MediaPageFrame } from '../../media/components/MediaPageFrame.tsx';
import { MediaThemeSettings } from '../../media/components/theme-settings.tsx';
import { MediaBrowseSettings } from '../../media/components/settings.tsx';
import { PlaybackSettings } from '../../media/components/playback-settings.tsx';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { ShelfSettingsFields } from '../../../ui/shelf-settings.tsx';
import { Icon } from '../../../ui/icon.tsx';
import '../styles/mine.css';
export const SETTINGS_SECTIONS = [
{ id: 'theme', title: '外观与主题' },
{ id: 'shelf', title: '书架' },
{ id: 'browse', title: '影音浏览' },
{ id: 'playback', title: '播放' },
] as const;
export type SettingsSection = typeof SETTINGS_SECTIONS[number]['id'];
function isSection(value: string | null | undefined): value is SettingsSection {
return SETTINGS_SECTIONS.some(section => section.id === value);
}
/**
* 偏好设置 (/me/settings): every personal preference on one page — how the app looks,
* the shelf, media browsing, playback — so none of them is a sheet in one place and
* a page in another. Every control applies the moment it changes; there is no save
* button anywhere on the page.
*
* `?section=` scrolls to one section, which is how old links (/me/theme, /me/browse,
* …) and the jump bar land. The shelf's gear still opens the 书架 fields in a sheet:
* that is a shortcut to this section, not a second setting.
*/
export function SettingsPage() {
const runtime = useRuntime();
const navigate = useNavigate();
const settings = useSettingsStore(state => state.settings);
const scope = runtime.mediaApi.preferenceScope();
const [params, setParams] = useSearchParams();
const requested = params.get('section');
const sections = useRef(new Map<SettingsSection, HTMLElement>());
useEffect(() => {
if (isSection(requested)) sections.current.get(requested)?.scrollIntoView({ block: 'start' });
}, [requested]);
const jump = (id: SettingsSection) => {
sections.current.get(id)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
setParams({ section: id }, { replace: true });
};
const ref = (id: SettingsSection) => (node: HTMLElement | null) => { if (node) sections.current.set(id, node); else sections.current.delete(id); };
const patch = (value: Parameters<typeof runtime.updateSettings>[0]) => { void runtime.updateSettings(value); };
return <MediaPageFrame className="media-secondary-page settings-page" title="偏好设置" backTo="/me" backLabel="返回我的">
<div className="settings-body">
<nav className="settings-jump" aria-label="跳到分区">
{SETTINGS_SECTIONS.map(section => <button type="button" key={section.id} aria-current={requested === section.id ? 'true' : undefined} onClick={() => jump(section.id)}>{section.title}</button>)}
</nav>
<section ref={ref('theme')} className="settings-section" aria-labelledby="settings-theme">
<h2 id="settings-theme">外观与主题</h2>
<MediaThemeSettings scope={scope} onReaderSettings={patch} />
</section>
<section ref={ref('shelf')} className="settings-section shelf-settings" aria-labelledby="settings-shelf">
<h2 id="settings-shelf">书架</h2>
<ShelfSettingsFields settings={settings} onPatch={patch} />
<Link className="settings-link" to="/me/opds" state={{ returnTo: '/me/settings?section=shelf' }}>
<span><strong>连接外部阅读器</strong><small>通过 OPDS,让其他阅读器只读访问你的书架</small></span>
<Icon name="chevron-right" size={16} />
</Link>
</section>
<section ref={ref('browse')} className="settings-section media-settings" aria-labelledby="settings-browse">
<h2 id="settings-browse">影音浏览</h2>
<p className="settings-note">仅影响影音页面,在当前设备为此服务器和账号保存</p>
<MediaBrowseSettings scope={scope} />
</section>
<section ref={ref('playback')} className="settings-section media-settings" aria-labelledby="settings-playback">
<h2 id="settings-playback">播放</h2>
<PlaybackSettings scope={scope} player={runtime.player} onPlayback={() => navigate('/media/video/player')} />
</section>
</div>
</MediaPageFrame>;
}
/**
* Old addresses of the preference panels: /me/theme, /me/browse, /me/playback (and
* /me/shelf), and the older media settings home /media/:channel/settings[/:panel].
* Each lands on its section of 偏好设置; 账号与连接 kept its own page.
*/
export function LegacySettingsRedirect() {
const { panel } = useParams();
if (panel === undefined) return <Navigate to="/me/settings" replace />;
if (panel === 'account') return <Navigate to="/me/account" replace />;
return <Navigate to={isSection(panel) ? `/me/settings?section=${panel}` : '/me'} replace />;
}
+54
View File
@@ -102,3 +102,57 @@
.mine-page .mine-row.is-danger { color: var(--media-error-ink); }
.mine-page .mine-row.is-danger .mine-row-icon { background: var(--media-error-background); color: var(--media-error-ink); }
/* 偏好设置 (/me/settings): one column of sections, each opened by its own heading.
* The sections bring their own controls (theme cards, the shelf's toolkit rows, the
* media fields); this only lays them out as one page. */
.settings-body { max-width: 640px; margin: 0 auto; padding-bottom: 40px; }
.settings-jump { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 4px; }
.settings-page .settings-jump button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--color-line);
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-ink);
font: inherit;
font-size: var(--text-label);
cursor: pointer;
}
.settings-page .settings-jump button[aria-current='true'] { border-color: var(--color-brand); background: var(--color-brand-soft); color: var(--color-brand-text); }
@media (hover: hover) and (pointer: fine) { .settings-page .settings-jump button:hover { background: var(--color-surface-2); } }
/* Clears the page's pinned title bar when a section is scrolled to. */
.settings-section { margin: 32px 0 0; scroll-margin-top: 88px; }
.settings-section > h2 { margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--color-line); font-size: var(--text-title); font-weight: 650; color: var(--color-ink); }
.settings-section h3 { margin: 22px 0 10px; font-size: var(--text-label); font-weight: 600; color: var(--color-muted); }
.settings-note { margin: 10px 0 0; font-size: var(--text-label); color: var(--color-muted); }
.settings-page .theme-settings { margin: 0; max-width: none; }
.settings-page .media-settings { max-width: none; }
.settings-page .media-settings .media-settings-panel { margin: 4px 0 0; }
/* The shelf's toolkit rows were drawn for a sheet: on the page their headings are
* plain sub-labels, not sticky bars. */
.settings-page .shelf-settings .section-title { position: static; margin: 22px 0 10px; padding: 0; border: 0; background: none; color: var(--color-muted); }
.settings-page .shelf-settings .segmented { width: 100%; }
.settings-page .settings-link {
display: flex;
align-items: center;
gap: 12px;
margin-top: 22px;
padding: 14px;
border: 1px solid var(--color-line);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
}
.settings-link > span { flex: 1; min-width: 0; }
.settings-link strong { display: block; font-size: var(--text-body); font-weight: 500; }
.settings-link small { display: block; margin-top: 2px; font-size: var(--text-meta); color: var(--color-muted); }
.settings-link svg { flex: none; color: var(--color-muted); }
@media (hover: hover) and (pointer: fine) { .settings-page .settings-link:hover { background: var(--color-surface-2); } }
/* 外部阅读器 and the 管理 pages: sheet-sized forms, given the same column. */
.opds-page .opds-access, .admin-page .user-management { max-width: 640px; margin: 16px auto 40px; }
@@ -6,11 +6,11 @@ import {SleepTimerOptions} from './sleep-timer.tsx';
export function PlaybackSettings({scope,player,onPlayback}:{scope:string;player:MediaPlayer;onPlayback?:()=>void}) {
const [value,setValue]=useState(()=>readPlaybackPreferences(scope)),[error,setError]=useState('');
function change(patch:Partial<typeof value>){const next={...value,...patch};try{savePlaybackPreferences(scope,next);setValue(next);setError('');}catch{setError('无法保存播放偏好,请检查设备存储。');}}
return <section className="media-settings-panel"><h2>播放偏好</h2>
return <section className="media-settings-panel">
<label className="media-setting-field">默认倍速<MediaSelect aria-label="默认倍速" value={value.defaultRate} onChange={event=>change({defaultRate:Number(event.currentTarget.value)})}>{playbackRates.map(rate=><option key={rate} value={rate}>{rate}×</option>)}</MediaSelect></label>
<label className="media-settings-check"><span>连续播放</span><input type="checkbox" checked={value.continuous} onChange={event=>change({continuous:event.currentTarget.checked})}/></label>
<p className="media-theme-note">默认倍速在结束后再次开始播放时使用。连续播放适用于网页播放器,原生播放器使用其自身的队列设置。</p>
{error&&<p role="alert">{error}</p>}
{player.active&&<><h2>当前播放</h2><div className="media-settings-playing"><strong>{player.title}</strong>{onPlayback&&<button onClick={onPlayback}>打开播放器</button>}</div><label className="media-setting-field">当前倍速<MediaSelect aria-label="播放倍速" value={player.playbackRate} onChange={event=>player.speed(Number(event.currentTarget.value))}>{playbackRates.map(rate=><option key={rate} value={rate}>{rate}×</option>)}</MediaSelect></label><h2>睡眠定时</h2><SleepTimerOptions player={player}/></>}
{player.active&&<><h3>当前播放</h3><div className="media-settings-playing"><strong>{player.title}</strong>{onPlayback&&<button onClick={onPlayback}>打开播放器</button>}</div><label className="media-setting-field">当前倍速<MediaSelect aria-label="播放倍速" value={player.playbackRate} onChange={event=>player.speed(Number(event.currentTarget.value))}>{playbackRates.map(rate=><option key={rate} value={rate}>{rate}×</option>)}</MediaSelect></label><h3>睡眠定时</h3><SleepTimerOptions player={player}/></>}
</section>;
}
+36 -94
View File
@@ -1,109 +1,51 @@
import { useState } from 'react';
import { ConfirmSheet } from '../../../ui/sheet.tsx';
import { Icon } from '../../../ui/toolkit.tsx';
import { MediaSelect } from './select.tsx';
import { PlaybackSettings } from './playback-settings.tsx';
import { MediaThemeSettings } from './theme-settings.tsx';
import type { AppSettings } from '../../../store/settings.ts';
import type { MediaPlayer } from '../services/player.ts';
import type { MediaAccount } from '../api/media-api.ts';
import { defaultMediaPreferences, saveMediaPreferences, type MediaPreferences } from '../services/preferences.ts';
export type SettingsPanel = 'theme' | 'browse' | 'playback' | 'account';
export const SETTINGS_TITLES: Record<SettingsPanel, string> = {
theme: '外观与主题',
browse: '影音浏览',
playback: '播放',
account: '账号与连接',
};
import { readMediaPreferences, saveMediaPreferences, type MediaPreferences } from '../services/preferences.ts';
/**
* One settings panel of 我的 (/me/:panel). There is no media settings home any
* more: 我的 is the list, and each row opens one of these panels.
*
* Only 影音浏览 holds a draft; leaving it with unsaved changes asks first.
* 影音浏览: the media screens' per-device browsing preferences, the 影音浏览 section
* of 我的 › 偏好设置. Like every other section there, a change applies at once;
* a storage failure is reported and the control returns to what is stored.
*/
export function MediaSettings({ scope, preferences, player, account, onSaved, onBack, panel, onReaderSettings, onPlayback, backLabel = '返回我的' }: {
scope: string;
preferences: MediaPreferences;
player: MediaPlayer;
account?: MediaAccount | null;
onSaved: (value: MediaPreferences) => void;
onBack: () => void;
panel: SettingsPanel;
onReaderSettings?: (patch: Partial<AppSettings>) => void;
onPlayback?: () => void;
backLabel?: string;
}) {
const [draft, setDraft] = useState({ ...preferences });
export function MediaBrowseSettings({ scope }: { scope: string }) {
const [value, setValue] = useState(() => readMediaPreferences(scope));
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const [leaving, setLeaving] = useState(false);
const dirty = draft.density !== preferences.density
|| draft.showContinue !== preferences.showContinue
|| draft.showLibraryName !== preferences.showLibraryName
|| (draft.sort ?? 'default') !== (preferences.sort ?? 'default');
function back() {
if (panel === 'browse' && dirty) { setLeaving(true); return; }
onBack();
}
function save() {
setError('');
setSaved(false);
function change(patch: Partial<MediaPreferences>) {
try {
onSaved(saveMediaPreferences(scope, draft));
setSaved(true);
setValue(saveMediaPreferences(scope, { ...value, ...patch }));
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '保存失败');
}
}
function change(patch: Partial<MediaPreferences>) {
setDraft({ ...draft, ...patch });
setSaved(false);
}
return <div className="media-settings-panel">
<label className="media-setting-field">封面密度
<MediaSelect aria-label="封面密度" value={value.density} onChange={event => change({ density: event.currentTarget.value as MediaPreferences['density'] })}>
<option value="compact">紧凑(手机三列)</option>
<option value="comfortable">宽松(手机两列)</option>
</MediaSelect>
</label>
<label className="media-setting-field">默认排序
<MediaSelect aria-label="默认排序" value={value.sort ?? 'default'} onChange={event => change({ sort: event.currentTarget.value as NonNullable<MediaPreferences['sort']> })}>
<option value="default">默认顺序</option>
<option value="title-asc">名称升序</option>
<option value="title-desc">名称降序</option>
</MediaSelect>
</label>
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" role="switch" checked={value.showContinue} onChange={event => change({ showContinue: event.currentTarget.checked })} /></label>
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" role="switch" checked={value.showLibraryName} onChange={event => change({ showLibraryName: event.currentTarget.checked })} /></label>
{error && <p role="alert">{error}</p>}
</div>;
}
return <section className="media-settings" aria-label={SETTINGS_TITLES[panel]}>
<div className="media-settings-heading">
<button type="button" className="media-back-button" data-media-back aria-label={backLabel} title={backLabel} onClick={back}><Icon name="chevron-left" /></button>
<h1>{SETTINGS_TITLES[panel]}</h1>
</div>
{leaving && <ConfirmSheet danger title="放弃浏览偏好修改?" text="浏览偏好尚未保存,离开后将丢弃本次修改。" confirmText="放弃修改" cancelText="继续编辑"
onCancel={() => setLeaving(false)}
onConfirm={() => { setLeaving(false); setDraft({ ...preferences }); onBack(); }} />}
{panel === 'theme' && <MediaThemeSettings scope={scope} {...(onReaderSettings ? { onReaderSettings } : {})} />}
{panel === 'browse' && <form className="media-form media-settings-panel" onSubmit={event => { event.preventDefault(); save(); }}>
<p>仅影响影音页面,在当前设备为此服务器和账号保存</p>
<label className="media-setting-field">封面密度
<MediaSelect aria-label="封面密度" value={draft.density} onChange={event => change({ density: event.currentTarget.value as MediaPreferences['density'] })}>
<option value="compact">紧凑(手机三列)</option>
<option value="comfortable">宽松(手机两列)</option>
</MediaSelect>
</label>
<label className="media-setting-field">默认排序
<MediaSelect aria-label="默认排序" value={draft.sort ?? 'default'} onChange={event => change({ sort: event.currentTarget.value as NonNullable<MediaPreferences['sort']> })}>
<option value="default">默认顺序</option>
<option value="title-asc">名称升序</option>
<option value="title-desc">名称降序</option>
</MediaSelect>
</label>
<label className="media-settings-check"><span>首页显示最近续播</span><input type="checkbox" role="switch" checked={draft.showContinue} onChange={event => change({ showContinue: event.currentTarget.checked })} /></label>
<label className="media-settings-check"><span>列表工具栏显示来源库</span><input type="checkbox" role="switch" 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 === '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>}
{panel === 'playback' && <PlaybackSettings scope={scope} player={player} {...(onPlayback ? { onPlayback } : {})} />}
/** 账号与连接: who is signed in and to which server. Never shows tokens. */
export function MediaAccountPanel({ account }: { account: MediaAccount }) {
return <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>;
}
@@ -92,12 +92,12 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string;
</> : <div className="theme-settings-row"><span>明暗</span><Chips label="明暗" options={modeOptions} value={resolved.mode} onChange={mode => save({ mode })} /></div>}
</div>
<h2>应用主题</h2>
<h3>应用主题</h3>
<div className="theme-grid" role="group" aria-label="应用主题">
{appThemes.map(theme => <ThemeCard key={theme.id} id={theme.id} label={theme.name} note="浅色 · 深色" pressed={themePressed(theme.id)} onClick={() => chooseTheme(theme.id)} />)}
</div>
<h2>阅读主题</h2>
<h3>阅读主题</h3>
<div className="theme-grid" role="group" aria-label="阅读主题">
<ThemeCard id={resolved.theme} label="跟随应用" note={'与应用一致 · ' + themes[resolved.theme].name} pressed={preference.reading === 'app'} onClick={() => save({ reading: 'app' })} />
<ThemeCard id="gray" label={themes.gray.name} note="阅读专用 · 浅色 · 深色" pressed={preference.reading === 'gray'} onClick={() => save({ reading: 'gray' })} />
@@ -105,7 +105,7 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string;
<p className="theme-settings-note">黑白灰只用于阅读器,可以与应用主题分开设置;浅色和夜间跟随应用的明暗。主题偏好按服务器和账号保存在此设备。</p>
{onReaderSettings && <>
<h2>阅读纸色</h2>
<h3>阅读纸色</h3>
<ReadingPapers themeId={resolveReadingTheme(preference, systemDark).theme} settings={{ paper, paperFollowsDark }} onChange={patch => { onReaderSettings(patch); setSaved(true); setError(''); }} />
<p className="theme-settings-note">纸色只改阅读页的底色和文字,工具栏仍用阅读主题;也可在阅读页的“外观”里切换。纸色按设备保存。</p>
</>}
@@ -1,43 +0,0 @@
import { useState } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { MediaPageFrame } from '../components/MediaPageFrame.tsx';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { MediaSettings, SETTINGS_TITLES, type SettingsPanel } from '../components/settings.tsx';
import { readMediaPreferences } from '../services/preferences.ts';
function isPanel(value: string | undefined): value is SettingsPanel {
return (Object.keys(SETTINGS_TITLES) as string[]).includes(value ?? '');
}
/**
* One settings panel of 我的, at /me/:panel. Back returns to 我的. An unknown panel,
* and the old media settings home (/media/:channel/settings[/:panel]), lead to 我的.
*/
export function MediaSettingsPage() {
const runtime = useRuntime();
const navigate = useNavigate();
const { channel: routeChannel, panel } = useParams();
const channel = routeChannel === 'music' || routeChannel === 'audiobook' ? routeChannel : 'video';
const [preferences, setPreferences] = useState(() => readMediaPreferences(runtime.mediaApi.preferenceScope()));
if (!isPanel(panel)) return <Navigate to="/me" replace />;
return <MediaPageFrame className="media-utility-workspace" title={SETTINGS_TITLES[panel]} backTo="/me" backLabel="返回我的">
<MediaSettings
scope={runtime.mediaApi.preferenceScope()}
preferences={preferences}
player={runtime.player}
account={runtime.mediaApi.accountInfo()}
panel={panel}
onSaved={setPreferences}
onBack={() => navigate('/me')}
onPlayback={() => navigate(`/media/${channel}/player`)}
onReaderSettings={patch => { void runtime.updateSettings(patch); }}
/>
</MediaPageFrame>;
}
/** Old links to the media settings home and its panels: the panels now live in 我的. */
export function LegacyMediaSettingsRedirect() {
const { panel } = useParams();
return <Navigate to={isPanel(panel) ? `/me/${panel}` : '/me'} replace />;
}
@@ -281,7 +281,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-settings .media-setting-field,.media-settings .media-settings-check {display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-bottom:1px solid var(--border);font-size:var(--text-ui)}
.media-settings .media-setting-field select {width:auto;max-width:60%;margin:0}
.media-settings .media-settings-check input {margin:0;width:18px;height:18px;min-height:18px;flex-shrink:0}
.media-settings-footer {display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:20px}
@media(max-width:700px){.media-settings-heading h1 {font-size:var(--text-title)}}
.media-task-library>button {display:flex;align-items:center;gap:6px}
@@ -701,9 +700,6 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-screen.media-video-page>.media-heading {background:var(--media-background);color:var(--text);border-color:var(--border)}
/* Settings and library forms share the prototype's single page heading. */
.media-utility-workspace .media-settings-heading {margin:0;min-height:49px}
.media-utility-workspace .media-settings-heading h1 {font-size:var(--text-title);line-height:1.4}
.media-utility-workspace .media-library-create>.media-form,.media-utility-workspace .media-library-editor,.media-utility-workspace .media-permissions-form {max-width:none;margin:24px 0 0;gap:16px}
.media-library-fields {display:grid;grid-template-columns:1fr 1fr;gap:16px;min-width:0}
.media-library-fields>div,.media-library-field {display:grid;gap:7px;min-width:0}
.media-library-fields span,.media-library-field>span,.media-library-editor>label,.media-library-create label {font-size:var(--text-meta)}
@@ -744,12 +740,9 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
.media-settings-playing svg {flex-shrink:0;color:var(--media-accent)}
.media-screen .media-settings-playing button {font-size:var(--text-meta);white-space:nowrap}
@media(min-width:701px){
.media-utility-workspace .media-settings-heading,.media-manager-workspace>.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;border:0;background:var(--media-background);box-shadow:inset 0 -1px var(--border)}
.media-manager-workspace>.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;border:0;background:var(--media-background);box-shadow:inset 0 -1px var(--border)}
}
@media(max-width:700px){
.media-utility-workspace {padding-top:0}
.media-utility-workspace .media-settings-heading {position:sticky;top:0;background:var(--media-background);z-index:4}
.media-utility-workspace .media-library-editor,.media-utility-workspace .media-library-create>.media-form,.media-utility-workspace .media-permissions-form {margin-top:17px}
.media-library-fields {grid-template-columns:1fr;gap:16px}
.media-account-panel {margin-top:24px}
}
@@ -838,7 +831,7 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
/* 外观与主题 (V3.1, prototype 23). */
.theme-settings {max-width:640px;margin:16px auto}
.theme-settings h2 {margin:22px 0 10px;font-size:var(--text-label);font-weight:600;color:var(--muted)}
.theme-settings :is(h2,h3) {margin:22px 0 10px;font-size:var(--text-label);font-weight:600;color:var(--muted)}
.theme-settings-card {overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--media-paper)}
.media-screen .theme-follow {display:flex;align-items:center;gap:12px;width:100%;min-height:64px;padding:14px;border-radius:0;text-align:left;color:var(--text)}
.theme-follow-icon {display:grid;place-items:center;flex:none;width:36px;height:36px;border-radius:var(--radius-md);background:var(--media-selection);color:var(--media-accent)}
+4 -4
View File
@@ -248,8 +248,8 @@
@media(min-width:701px){
.media-screen>.media-heading:has(.media-tabs:not([hidden])) {display:block;height:auto;min-height:0;margin:0;border:0;padding:0}
.media-screen .media-mobile-title {display:none}
.media-screen:is(.media-secondary-page,.media-utility-workspace,.media-playback-page) .media-heading,.media-utility-workspace .media-settings-heading {height:68px;min-height:68px}
.media-screen:is(.media-secondary-page,.media-playback-page,.media-manager-workspace)>.media-heading,.media-screen.media-metadata-workspace>.media-metadata-host>.media-heading,.media-screen:has(>.media-detail-page)>.media-heading,.media-utility-workspace .media-settings-heading {left:max(24px,calc((100vw - 1060px)/2))}
.media-screen:is(.media-secondary-page,.media-playback-page) .media-heading {height:68px;min-height:68px}
.media-screen:is(.media-secondary-page,.media-playback-page,.media-manager-workspace)>.media-heading,.media-screen.media-metadata-workspace>.media-metadata-host>.media-heading,.media-screen:has(>.media-detail-page)>.media-heading {left:max(24px,calc((100vw - 1060px)/2))}
.media-screen .media-mobile-title {height:68px;left:max(145px,calc((100vw - 1056px)/2 + 110px))}
.media-heading:has(.media-mobile-title) .media-heading-actions {top:12px}
.media-screen .media-playback-status[data-panel=main] {display:grid;grid-template-columns:360px minmax(0,1fr);grid-template-rows:1fr auto auto auto 1fr;column-gap:64px;min-height:540px;align-content:center}
@@ -753,13 +753,13 @@
/* Secondary routes own the page chrome. The channel rail belongs to catalogue
landing pages and must not remain visible behind a secondary title row. */
.media-screen:is(.media-secondary-page,.media-manager-workspace,.media-playback-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 {
.media-screen:is(.media-secondary-page,.media-manager-workspace,.media-playback-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-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-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 {
.media-screen:is(.media-secondary-page,.media-manager-workspace,.media-playback-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-narrators:not([data-view=directory])>.media-narrator-heading {
height:68px;
min-height:68px;
}
+3 -5
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { coverTint } from '../../../ui/cover-tint.ts';
import { BookGridSkeleton } from '../../../ui/book-grid-skeleton.tsx';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type { Book, ContinueReadingItem, ReadingState } from '../../../api/types.ts';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
@@ -9,7 +9,6 @@ import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { useShelf, useShelfGroups, type ShelfFilters } from '../queries/shelf.queries.ts';
import { SHELF_SORTS, ShelfSettingsSheet } from '../../../ui/shelf-settings.tsx';
import { IconButton, Callout, Icon } from '../../../ui/toolkit.tsx';
import { OpdsAccess } from '../../../ui/opds-access.tsx';
import { DialogView, type Dialog } from '../../../ui/dialog.tsx';
import { FloatingNotice } from '../../../ui/floating-notice.tsx';
import { useSwipeTabs } from '../../../shared/ui/use-swipe-tabs.ts';
@@ -75,7 +74,7 @@ export function ShelfPage() {
const [search, setSearch] = useState(queryText);
useEffect(() => { setSearch(queryText); }, [queryText]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [opdsOpen, setOpdsOpen] = useState(false);
const navigate = useNavigate();
const [removeTarget, setRemoveTarget] = useState<Book | ContinueReadingItem | null>(null);
const [groupsOpen, setGroupsOpen] = useState(false);
const [actionError, setActionError] = useState('');
@@ -177,8 +176,7 @@ export function ShelfPage() {
{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} menu={menuItems(book)} />)}</div>}
{query.data && pages > 1 && <Pager className="shelf-pager" page={Math.min(page, pages)} pageCount={pages} busy={query.isFetching} onGo={next => go({ page: next })} />}
</section>
{settingsOpen && <ShelfSettingsSheet 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); }} />}
{settingsOpen && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); navigate('/me/opds', { state: { returnTo: '/shelf' } }); }} />}
{groupsOpen && <DialogView dialog={{ kind: 'pick', title: groups.isPending ? '分组 · 正在读取…' : groups.error ? '分组 · 读取失败' : '分组', options: groupOptions, resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="" onClose={answer => {
setGroupsOpen(false);
if (typeof answer !== 'string') return;
+2 -1
View File
@@ -8,7 +8,7 @@ import {
Settings, SlidersVertical, Bookmark, Music, ListMusic, ListVideo, ListPlus,
ListOrdered, Layers, Heart, Star, Clapperboard, UserRound, Lock, LockOpen, Gauge,
Subtitles, AudioLines, MicVocal, Shuffle, Repeat, Repeat1, Maximize, Minimize, Expand,
Shrink, LocateFixed, LayoutGrid, House, Rss,
Shrink, LocateFixed, LayoutGrid, House, Rss, UsersRound, Ticket, Link2, ServerCog,
} from 'lucide-react';
import type { CSSProperties, JSX } from 'react';
@@ -55,6 +55,7 @@ export const ICONS = {
// state and people
eye: Eye, 'eye-off': EyeOff, info: Info, warning: TriangleAlert, offline: CloudOff,
clock: Clock, history: History, favorite: Heart, star: Star, account: UserRound,
users: UsersRound, invite: Ticket, connect: Link2, server: ServerCog,
} as const;
export type IconName = keyof typeof ICONS;
+8 -6
View File
@@ -1,11 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { ApiError, type ReaderApi } from '../api/client.ts';
import type { OpdsCredential } from '../api/sources.ts';
import { Sheet } from './sheet.tsx';
import { Button } from './toolkit.tsx';
import { FloatingNotice } from './floating-notice.tsx';
export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onClose(): void; onSignedOut(): void }) {
/**
* OPDS credentials for the signed-in account: the catalogue URL, and one revocable
* username/password per external reader. A page of 我的 (/me/opds), reached from
* 我的, from 偏好设置 › 书架, and from the shelf's settings sheet.
*/
export function OpdsAccess({ api, onSignedOut }: { api: ReaderApi; onSignedOut(): void }) {
const [items, setItems] = useState<OpdsCredential[]>([]), [name, setName] = useState('');
const [created, setCreated] = useState<{ username: string; password: string } | null>(null);
const [url, setUrl] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
@@ -27,8 +31,7 @@ export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onCl
setItems(value.credentials); setUrl(new URL(value.catalogUrl, api.baseUrl || location.href).href);
};
useEffect(() => { void run(reload); return () => { active.current = false; }; }, []);
return <Sheet title="连接外部阅读器" busy={busy} onClose={onClose}>
<div className="sheet-form opds-access">
return <div className="sheet-form opds-access">
<FloatingNotice message={busy ? '正在处理…' : error} busy={busy} error={!!error} />
<p className="sheet-text">通过 OPDS 1.2 只读目录访问当前账号书架中的文件书籍。章节书和目录漫画暂不发布</p>
<label className="sheet-field">目录地址<input readOnly value={url} /></label>
@@ -47,6 +50,5 @@ export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onCl
</form>
{items.map(item => <article className="sources-row" key={item.id}><div><strong>{item.name}</strong><small>到期:{new Date(item.expiresAt).toLocaleDateString()}</small></div>
<Button disabled={busy} onClick={() => void run(async () => { await api.revokeOpdsCredential(item.id); if (!active.current) return; if (created?.username === item.id) setCreated(null); await reload(); })}>撤销 {item.name}</Button></article>)}
</div>
</Sheet>;
</div>;
}
+22 -8
View File
@@ -1,7 +1,8 @@
/**
* The shelf's own settings.
*
* A sheet rather than a route, and a separate file rather than more rows in the
* On the shelf, a sheet rather than a route (a shortcut to the 书架 section of
* 我的 › 偏好设置, which shows the same fields); and a separate file rather than more rows in the
* reader's panel — because the two answer different questions. The reader panel
* is about the *page*: type size, line height, the engine that reads it aloud.
* This one is about the *grid*: how many covers fit, what each cover shows, and
@@ -54,14 +55,14 @@ export interface ShelfSettingsOptions {
onOpenExternalReader(): void;
}
export function ShelfSettingsSheet({
settings,
onPatch,
onClose,
onOpenExternalReader,
}: ShelfSettingsOptions): JSX.Element {
/**
* The shelf's controls themselves, without a container: the shelf's gear opens them
* in a sheet, and 我的 › 偏好设置 shows them as its 书架 section. One component, so
* the two can never offer different choices.
*/
export function ShelfSettingsFields({ settings, onPatch }: Pick<ShelfSettingsOptions, 'settings' | 'onPatch'>): JSX.Element {
return (
<Sheet className="shelf-settings" title="书架设置" onClose={onClose}>
<>
<SectionTitle>封面大小</SectionTitle>
<Segmented
label="封面大小"
@@ -96,6 +97,19 @@ export function ShelfSettingsSheet({
value={settings.shelfSort}
onChange={(value) => onPatch({ shelfSort: value })}
/>
</>
);
}
export function ShelfSettingsSheet({
settings,
onPatch,
onClose,
onOpenExternalReader,
}: ShelfSettingsOptions): JSX.Element {
return (
<Sheet className="shelf-settings" title="书架设置" onClose={onClose}>
<ShelfSettingsFields settings={settings} onPatch={onPatch} />
<SectionTitle>外部阅读器</SectionTitle>
<div className="notice">通过 OPDS,让其他阅读器只读访问你的书架</div>
<Button onClick={onOpenExternalReader}>连接外部阅读器</Button>
+66 -37
View File
@@ -1,46 +1,75 @@
import { useEffect, useState } from 'react';
import type { ReaderApi } from '../api/client.ts';
import type { ManagedUser, RegistrationSettings } from '../api/types.ts';
import { ConfirmSheet, Sheet } from './sheet.tsx';
import { ConfirmSheet } from './sheet.tsx';
import { FloatingNotice } from './floating-notice.tsx';
import {BusinessSettings} from './business-settings.tsx';
export function SystemSettings({api,onClose}:{api:ReaderApi;onClose():void}) {
const [users,setUsers]=useState<ManagedUser[]>([]),[settings,setSettings]=useState<RegistrationSettings|null>(null);
const [tab,setTab]=useState<'users'|'registration'|'business'>('users'),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState('');
const [form,setForm]=useState(false),[username,setUsername]=useState(''),[password,setPassword]=useState(''),[displayName,setDisplayName]=useState(''),[role,setRole]=useState<'admin'|'member'>('member');
const [reset,setReset]=useState<ManagedUser|null>(null),[confirmation,setConfirmation]=useState<{text:string;run:()=>Promise<unknown>}|null>(null);
const [label,setLabel]=useState(''),[maxUses,setMaxUses]=useState(1),[days,setDays]=useState(7),[code,setCode]=useState('');
const [query,setQuery]=useState('');
async function load() { const [list,config]=await Promise.all([api.adminUsers(),api.registrationSettings()]);setUsers(list.users);setSettings(config); }
async function work(action:()=>Promise<unknown>,success='已保存') { if(busy)return;setBusy(true);setError('');setMessage('');try{await action();await load();setConfirmation(null);setMessage(success);}catch(e){setError(e instanceof Error?e.message:'操作失败');}finally{setBusy(false);} }
useEffect(()=>{void work(async()=>{},'');},[]);
const self=api.currentSession()?.user.id;
return <Sheet title="系统设置" size="wide" busy={busy} onClose={onClose}><div className="user-management">
<nav className="extension-tabs" aria-label="系统设置分类"><button aria-pressed={tab==='users'} onClick={()=>setTab('users')}>用户管理</button><button aria-pressed={tab==='registration'} onClick={()=>setTab('registration')}>注册与邀请</button><button aria-pressed={tab==='business'} onClick={()=>setTab('business')}>服务配置</button></nav>
/**
* Administration of the server's accounts, split by job: 用户管理 and 注册与邀请
* are each a page of 我的 › 管理 (服务配置 is BusinessSettings). Each panel loads
* what it shows, runs one write at a time, re-fetches after it, and only then
* reports — never a guess at what the server now holds.
*/
function useAdminWork<T>(load: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [busy, setBusy] = useState(false), [error, setError] = useState(''), [message, setMessage] = useState('');
const [confirmation, setConfirmation] = useState<{ text: string; run: () => Promise<unknown> } | null>(null);
async function work(action: () => Promise<unknown>, success = '已保存') {
if (busy) return;
setBusy(true); setError(''); setMessage('');
try { await action(); setData(await load()); setConfirmation(null); setMessage(success); }
catch (e) { setError(e instanceof Error ? e.message : '操作失败'); }
finally { setBusy(false); }
}
useEffect(() => { void work(async () => {}, ''); }, []);
const notice = <>
<FloatingNotice message={error || message} error={!!error} kind="success" />
{confirmation && <ConfirmSheet text={confirmation.text} onCancel={()=>setConfirmation(null)} onConfirm={()=>{setConfirmation(null);void work(confirmation.run);}} />}
{tab==='users' ? <>
<div className="user-manager-actions"><input aria-label="筛选用户" placeholder="搜索用户名或显示名" value={query} onInput={e=>setQuery(e.currentTarget.value)} /><button className="button primary" disabled={busy} onClick={()=>{setForm(!form);setReset(null);setPassword('');}}>新增用户</button></div>
{form && <form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{await api.adminCreateUser({username:username.trim(),password,displayName:displayName.trim(),role});setForm(false);setUsername('');setPassword('');setDisplayName('');setRole('member');},'用户已创建');}}>
<label>用户名<input required minLength={3} maxLength={100} value={username} onInput={e=>setUsername(e.currentTarget.value)} /></label>
<label>初始密码<input required type="password" autoComplete="new-password" minLength={8} maxLength={1024} value={password} onInput={e=>setPassword(e.currentTarget.value)} /></label>
<label>显示名<input maxLength={100} value={displayName} onInput={e=>setDisplayName(e.currentTarget.value)} /></label>
<label>账号角色<select value={role} onChange={e=>setRole(e.currentTarget.value as 'admin'|'member')}><option value="member">普通用户</option><option value="admin">管理员</option></select></label>
<button className="button primary" disabled={busy}>创建账号</button>
</form>}
{reset && <form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{await api.adminResetPassword(reset.id,password);setReset(null);setPassword('');},'密码已重置,旧会话已失效');}}><p>重置 {reset.username} 的密码,所有旧会话将立即失效。</p><label>新密码<input type="password" required minLength={8} maxLength={1024} autoComplete="new-password" value={password} onInput={e=>setPassword(e.currentTarget.value)} /></label><button className="button primary" disabled={busy}>确认重置密码</button><button type="button" className="button" disabled={busy} onClick={()=>{setReset(null);setPassword('');}}>取消重置</button></form>}
<p className="muted">共 {users.length} 个账号</p><ul className="user-manager-list">{users.filter(user=>(user.username+' '+user.displayName).toLowerCase().includes(query.toLowerCase())).map(user=><li key={user.id}>
<div><strong>{user.displayName}</strong><span className="muted"> @{user.username}{user.id===self?' · 当前账号':''}</span></div>
<p>{user.role==='admin'?'管理员':'普通用户'} · {user.disabled?'已停用':'正常'}</p>
{user.id!==self && <div className="user-manager-actions"><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`确定${user.disabled?'启用':'停用'} ${user.username}?${user.disabled?'':'旧会话将立即失效。'}`,run:()=>api.adminUpdateUser(user.id,{disabled:!user.disabled})})}>{user.disabled?'启用':'停用'}</button><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`将 ${user.username} ${user.role==='admin'?'改为普通用户':'设为管理员(可管理全部用户及实例设置)'}?`,run:()=>api.adminUpdateUser(user.id,{role:user.role==='admin'?'member':'admin'})})}>{user.role==='admin'?'移除管理员':'设为管理员'}</button><button className="button" disabled={busy} onClick={()=>{setReset(user);setForm(false);setPassword('');}}>重置密码</button></div>}
</li>)}</ul>
</> : tab==='business'?<BusinessSettings api={api}/>:settings && <>
<label className="user-manager-mode">注册方式<select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e=>{const mode=e.currentTarget.value as RegistrationSettings['mode'];setConfirmation({text:`将注册方式修改为${mode==='closed'?'关闭注册':mode==='open'?'开放注册(任何访问者都可创建账号)':'仅邀请码注册'}?`,run:()=>api.setRegistrationMode(mode)});}}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></select></label>
{confirmation && <ConfirmSheet text={confirmation.text} onCancel={() => setConfirmation(null)} onConfirm={() => { const run = confirmation.run; setConfirmation(null); void work(run); }} />}
</>;
return { data, busy, work, confirm: setConfirmation, notice };
}
export function UserManagement({ api }: { api: ReaderApi }) {
const { data, busy, work, confirm, notice } = useAdminWork(async () => (await api.adminUsers()).users);
const users: ManagedUser[] = data ?? [];
const [form, setForm] = useState(false), [username, setUsername] = useState(''), [password, setPassword] = useState(''), [displayName, setDisplayName] = useState(''), [role, setRole] = useState<'admin' | 'member'>('member');
const [reset, setReset] = useState<ManagedUser | null>(null), [query, setQuery] = useState('');
const self = api.currentSession()?.user.id;
return <div className="user-management">
{notice}
<div className="user-manager-actions"><input aria-label="筛选用户" placeholder="搜索用户名或显示名" value={query} onInput={e => setQuery(e.currentTarget.value)} /><button className="button primary" disabled={busy} onClick={() => { setForm(!form); setReset(null); setPassword(''); }}>新增用户</button></div>
{form && <form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { await api.adminCreateUser({ username: username.trim(), password, displayName: displayName.trim(), role }); setForm(false); setUsername(''); setPassword(''); setDisplayName(''); setRole('member'); }, '用户已创建'); }}>
<label>用户名<input required minLength={3} maxLength={100} value={username} onInput={e => setUsername(e.currentTarget.value)} /></label>
<label>初始密码<input required type="password" autoComplete="new-password" minLength={8} maxLength={1024} value={password} onInput={e => setPassword(e.currentTarget.value)} /></label>
<label>显示名<input maxLength={100} value={displayName} onInput={e => setDisplayName(e.currentTarget.value)} /></label>
<label>账号角色<select value={role} onChange={e => setRole(e.currentTarget.value as 'admin' | 'member')}><option value="member">普通用户</option><option value="admin">管理员</option></select></label>
<button className="button primary" disabled={busy}>创建账号</button>
</form>}
{reset && <form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { await api.adminResetPassword(reset.id, password); setReset(null); setPassword(''); }, '密码已重置,旧会话已失效'); }}><p>重置 {reset.username} 的密码,所有旧会话将立即失效。</p><label>新密码<input type="password" required minLength={8} maxLength={1024} autoComplete="new-password" value={password} onInput={e => setPassword(e.currentTarget.value)} /></label><button className="button primary" disabled={busy}>确认重置密码</button><button type="button" className="button" disabled={busy} onClick={() => { setReset(null); setPassword(''); }}>取消重置</button></form>}
<p className="muted">共 {users.length} 个账号</p>
<ul className="user-manager-list">{users.filter(user => (user.username + ' ' + user.displayName).toLowerCase().includes(query.toLowerCase())).map(user => <li key={user.id}>
<div><strong>{user.displayName}</strong><span className="muted"> @{user.username}{user.id === self ? ' · 当前账号' : ''}</span></div>
<p>{user.role === 'admin' ? '管理员' : '普通用户'} · {user.disabled ? '已停用' : '正常'}</p>
{user.id !== self && <div className="user-manager-actions">
<button className="button" disabled={busy} onClick={() => confirm({ text: `确定${user.disabled ? '启用' : '停用'} ${user.username}?${user.disabled ? '' : '旧会话将立即失效。'}`, run: () => api.adminUpdateUser(user.id, { disabled: !user.disabled }) })}>{user.disabled ? '启用' : '停用'}</button>
<button className="button" disabled={busy} onClick={() => confirm({ text: `将 ${user.username} ${user.role === 'admin' ? '改为普通用户' : '设为管理员(可管理全部用户及实例设置)'}?`, run: () => api.adminUpdateUser(user.id, { role: user.role === 'admin' ? 'member' : 'admin' }) })}>{user.role === 'admin' ? '移除管理员' : '设为管理员'}</button>
<button className="button" disabled={busy} onClick={() => { setReset(user); setForm(false); setPassword(''); }}>重置密码</button>
</div>}
</li>)}</ul>
</div>;
}
export function RegistrationManagement({ api }: { api: ReaderApi }) {
const { data: settings, busy, work, confirm, notice } = useAdminWork<RegistrationSettings>(() => api.registrationSettings());
const [label, setLabel] = useState(''), [maxUses, setMaxUses] = useState(1), [days, setDays] = useState(7), [code, setCode] = useState('');
return <div className="user-management">
{notice}
{settings && <>
<label className="user-manager-mode">注册方式<select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e => { const mode = e.currentTarget.value as RegistrationSettings['mode']; confirm({ text: `将注册方式修改为${mode === 'closed' ? '关闭注册' : mode === 'open' ? '开放注册(任何访问者都可创建账号)' : '仅邀请码注册'}?`, run: () => api.setRegistrationMode(mode) }); }}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></select></label>
<p className="muted">关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。</p>
<details><summary>创建邀请码</summary><form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{setCode((await api.createInvite({label,maxUses,days})).code);},'邀请码已创建');}}><label>备注<input maxLength={80} value={label} onInput={e=>setLabel(e.currentTarget.value)} /></label><label>可用次数<input type="number" required min={1} max={1000} value={maxUses} onInput={e=>setMaxUses(Number(e.currentTarget.value))} /></label><label>有效天数<input type="number" required min={1} max={365} value={days} onInput={e=>setDays(Number(e.currentTarget.value))} /></label><button className="button primary" disabled={busy}>生成邀请码</button></form></details>
{code && <label className="user-manager-mode">新邀请码(仅本次显示,请复制保存)<input aria-label="新邀请码" readOnly value={code} onFocus={e=>e.currentTarget.select()} /></label>}
<ul className="user-manager-list">{settings.invites.map(invite=><li key={invite.id}><strong>{invite.label||'未备注邀请码'}</strong><p>已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled?'已停用':invite.expiresAt<=Date.now()?'已过期':invite.usedCount>=invite.maxUses?'已用完':'可用'}</p><p className="muted">有效至 {new Date(invite.expiresAt).toLocaleString()}</p>{!invite.disabled && <button className="button" disabled={busy} onClick={()=>setConfirmation({text:'停用此邀请码?已注册账号不受影响。',run:()=>api.disableInvite(invite.id)})}>停用邀请码</button>}</li>)}</ul>
<details><summary>创建邀请码</summary><form className="user-manager-form" onSubmit={e => { e.preventDefault(); void work(async () => { setCode((await api.createInvite({ label, maxUses, days })).code); }, '邀请码已创建'); }}><label>备注<input maxLength={80} value={label} onInput={e => setLabel(e.currentTarget.value)} /></label><label>可用次数<input type="number" required min={1} max={1000} value={maxUses} onInput={e => setMaxUses(Number(e.currentTarget.value))} /></label><label>有效天数<input type="number" required min={1} max={365} value={days} onInput={e => setDays(Number(e.currentTarget.value))} /></label><button className="button primary" disabled={busy}>生成邀请码</button></form></details>
{code && <label className="user-manager-mode">新邀请码(仅本次显示,请复制保存)<input aria-label="新邀请码" readOnly value={code} onFocus={e => e.currentTarget.select()} /></label>}
<ul className="user-manager-list">{settings.invites.map(invite => <li key={invite.id}><strong>{invite.label || '未备注邀请码'}</strong><p>已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled ? '已停用' : invite.expiresAt <= Date.now() ? '已过期' : invite.usedCount >= invite.maxUses ? '已用完' : '可用'}</p><p className="muted">有效至 {new Date(invite.expiresAt).toLocaleString()}</p>{!invite.disabled && <button className="button" disabled={busy} onClick={() => confirm({ text: '停用此邀请码?已注册账号不受影响。', run: () => api.disableInvite(invite.id) })}>停用邀请码</button>}</li>)}</ul>
</>}
</div></Sheet>;
</div>;
}
+15 -33
View File
@@ -3,7 +3,8 @@ import { render } from '../src/shared/ui/render-root.ts';
import {afterEach,expect,it,vi} from 'vitest';
import {act} from 'react';
import {MediaSettings} from '../src/features/media/components/settings.tsx';
import {MediaAccountPanel,MediaBrowseSettings} from '../src/features/media/components/settings.tsx';
import {PlaybackSettings} from '../src/features/media/components/playback-settings.tsx';
import {defaultMediaPreferences,readMediaPreferences,saveMediaPreferences} from '../src/features/media/services/preferences.ts';
import type {MediaPlayer} from '../src/features/media/services/player.ts';
import {MediaApi} from '../src/features/media/api/media-api.ts';
@@ -18,50 +19,31 @@ it('isolates device preferences by server/account and leaves reading storage unt
expect(localStorage.getItem('reader.settings')).toBe('unchanged');
localStorage.setItem('reader.media.preferences.v1:'+first,'broken');expect(readMediaPreferences(first)).toEqual(defaultMediaPreferences);
});
const player={active:false} as MediaPlayer;
const change=(select:HTMLSelectElement,value:string)=>act(()=>{select.value=value;select.dispatchEvent(new Event('change',{bubbles:true}));});
const back=()=>act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回我的"]')!.click());
it('saves explicit changes and reports storage errors without applying them',async()=>{
const onSaved=vi.fn();
await act(async()=>render(<MediaSettings scope="test" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={onSaved} onBack={vi.fn()}/>,root));
it('applies browse preferences as they change, and keeps the stored value when storage fails',async()=>{
await act(async()=>render(<MediaBrowseSettings scope="test"/>,root));
change(root.querySelector('select')!,'comfortable');
expect(onSaved).not.toHaveBeenCalled();
await act(async()=>{root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});
expect(onSaved).toHaveBeenCalledWith({...defaultMediaPreferences,density:'comfortable'});
expect(readMediaPreferences('test').density).toBe('comfortable');
act(()=>root.querySelector<HTMLInputElement>('input[type=checkbox]')!.click());
expect(readMediaPreferences('test').showContinue).toBe(false);
vi.spyOn(Storage.prototype,'setItem').mockImplementation(()=>{throw new Error('quota');});
await act(async()=>{root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});
expect(root.querySelector('[role=alert]')?.textContent).toContain('无法保存');expect(onSaved).toHaveBeenCalledTimes(1);
change(root.querySelector('select')!,'compact');
expect(root.querySelector('[role=alert]')?.textContent).toContain('无法保存');
expect(root.querySelector('select')!.value).toBe('comfortable');
expect(readMediaPreferences('test').density).toBe('comfortable');
});
it('uses the active player for live playback options',async()=>{
const speed=vi.fn(),sleep=vi.fn(),onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="test" panel="playback" preferences={defaultMediaPreferences} player={{active:true,playbackRate:1,sleepAt:0,speed,sleep} as unknown as MediaPlayer} onSaved={vi.fn()} onBack={onBack}/>,root));
const speed=vi.fn(),sleep=vi.fn(),onPlayback=vi.fn();
await act(async()=>render(<PlaybackSettings scope="test" player={{active:true,title:'正在播放',playbackRate:1,sleepAt:0,speed,sleep} as unknown as MediaPlayer} onPlayback={onPlayback}/>,root));
expect(root.querySelector('[aria-label="默认倍速"]')).not.toBeNull();expect(root.textContent).toContain('连续播放');
const current=root.querySelector<HTMLSelectElement>('[aria-label="播放倍速"]')!;
act(()=>{current.value='1.5';current.dispatchEvent(new Event('change',{bubbles:true}));Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='30分钟')!.click();});
expect(speed).toHaveBeenCalledWith(1.5);expect(sleep).toHaveBeenCalledWith(30);
back();expect(onBack).toHaveBeenCalled();
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='打开播放器')!.click());expect(onPlayback).toHaveBeenCalled();
});
it('shows account identity without exposing credentials',async()=>{
const api=new MediaApi({baseUrl:'https://reader.example',currentSession:()=>({user:{username:'lin',displayName:'林间',role:'member'},accessToken:'secret-access',refreshToken:'secret-refresh'})} as unknown as ReaderApi);
expect(api.accountInfo()).toEqual({username:'lin',displayName:'林间',role:'member',server:'https://reader.example'});
await act(async()=>render(<MediaSettings scope="member" panel="account" preferences={defaultMediaPreferences} player={player} account={api.accountInfo()} onSaved={vi.fn()} onBack={vi.fn()}/>,root));
await act(async()=>render(<MediaAccountPanel account={api.accountInfo()!}/>,root));
expect(root.textContent).toContain('@lin');expect(root.textContent).toContain('https://reader.example');expect(root.textContent).not.toContain('secret');
});
it('asks before dropping an unsaved preference draft',async()=>{
const onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="draft" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={vi.fn()} onBack={onBack}/>,root));
change(root.querySelector('select')!,'comfortable');
back();
expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');expect(onBack).not.toHaveBeenCalled();
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='继续编辑')!.click());
expect(root.querySelector('select')!.value).toBe('comfortable');
back();
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='放弃修改')!.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
it('leaves without asking when nothing changed',async()=>{
const onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="clean" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={vi.fn()} onBack={onBack}/>,root));
back();
expect(root.querySelector('[role=alertdialog]')).toBeNull();expect(onBack).toHaveBeenCalledTimes(1);
});
+11 -15
View File
@@ -33,7 +33,7 @@ afterEach(() => {
});
describe('我的', () => {
it('shows who is signed in, which server, and links to the pages that own each setting', () => {
it('shows who is signed in, which server, and opens every row as a page', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
expect(root.querySelector('.mine-profile')?.textContent).toContain('林间');
@@ -42,30 +42,26 @@ describe('我的', () => {
const links = [...root.querySelectorAll<HTMLAnchorElement>('a.mine-row')].map(a => [a.textContent, a.getAttribute('href')]);
expect(links).toEqual([
['书库', '/library'], ['书源', '/sources'], ['时长统计阅读、观影、听书', '/me/stats'],
['外观与主题跟随系统', '/me/theme'], ['影音浏览紧凑', '/me/browse'], ['播放', '/me/playback'],
['账号与连接', '/me/account'],
['偏好设置跟随系统', '/me/settings'],
['账号与连接', '/me/account'], ['外部阅读器OPDS', '/me/opds'],
]);
// Only signing out is a button (it asks first); nothing else opens a sheet in place.
expect([...root.querySelectorAll('button.mine-row')].map(b => b.textContent)).toEqual(['退出登录']);
// Members see no administration group.
expect(root.querySelector('[aria-label="管理"]')).toBeNull();
});
it('opens the shelf display sheet in place', () => {
useAuthStore.setState({ verifiedUser: user('member') });
paint();
const sheet = () => root.querySelector<HTMLElement>('dialog[aria-label="书架设置"]');
expect(sheet()).toBeNull();
act(() => { [...root.querySelectorAll<HTMLButtonElement>('button.mine-row')].find(b => b.textContent?.startsWith('书架显示'))!.click(); });
expect(sheet()?.hasAttribute('open')).toBe(true);
});
it('gathers administration under 管理 for administrators and shows the chosen theme', () => {
it('gathers administration under 管理 for administrators, one page per job, and shows the chosen theme', () => {
saveThemePreference('mine-test', { theme: 'blue', mode: 'dark' });
useAuthStore.setState({ verifiedUser: user('admin') });
paint();
const labels = [...root.querySelectorAll('.mine-row-label')].map(el => el.textContent);
expect(labels).toContain('媒体库');
const admin = [...root.querySelectorAll('[aria-label="管理"] .mine-row-label')].map(el => el.textContent);
expect(admin).toEqual(['系统设置', '扫描与刮削']);
const admin = [...root.querySelectorAll<HTMLAnchorElement>('[aria-label="管理"] a.mine-row')].map(a => [a.querySelector('.mine-row-label')?.textContent, a.getAttribute('href')]);
expect(admin).toEqual([
['用户管理', '/me/admin/users'], ['注册与邀请', '/me/admin/registration'], ['服务配置', '/me/admin/services'],
['扫描与刮削', '/media/video/settings/tasks'],
]);
expect(root.textContent).toContain('雾蓝 · 深色');
expect(root.querySelector('.mine-profile')?.textContent).toContain('管理员');
});
+9 -3
View File
@@ -16,9 +16,15 @@ describe('React Router route tree', () => {
['/media/video/history', 'MediaHistoryPage'],
['/media/queue', 'MediaQueuePage'],
['/media/music/queue', 'MediaQueuePage'],
['/media/video/settings', 'LegacyMediaSettingsRedirect'],
['/media/video/settings/browse', 'LegacyMediaSettingsRedirect'],
['/me/browse', 'MediaSettingsPage'],
['/media/video/settings', 'LegacySettingsRedirect'],
['/media/video/settings/browse', 'LegacySettingsRedirect'],
['/me/settings', 'SettingsPage'],
['/me/browse', 'LegacySettingsRedirect'],
['/me/account', 'AccountPage'],
['/me/opds', 'OpdsPage'],
['/me/admin/users', 'AdminUsersPage'],
['/me/admin/registration', 'AdminRegistrationPage'],
['/me/admin/services', 'AdminServicesPage'],
['/media/video/settings/libraries', 'MediaLibrariesPage'],
['/media/video/settings/tasks', 'MediaTasksPage'],
['/media/video/folders/library-1', 'MediaFoldersPage'],
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { render } from '../src/shared/ui/render-root.ts';
import { LegacySettingsRedirect, SettingsPage } from '../src/features/account/pages/SettingsPage.tsx';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { readMediaPreferences } from '../src/features/media/services/preferences.ts';
const root = document.createElement('div');
document.body.append(root);
const updateSettings = vi.fn(async () => undefined);
const runtime = {
mediaApi: { preferenceScope: () => 'settings-test' },
player: { active: false },
updateSettings,
} as any;
function Where() { const location = useLocation(); return <p data-where={location.pathname + location.search} />; }
const paint = (entry: string) => act(() => render(
<RuntimeContext.Provider value={runtime}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/me/settings" element={<><SettingsPage /><Where /></>} />
<Route path="/me/account" element={<Where />} />
<Route path="/me" element={<Where />} />
<Route path="/me/:panel" element={<LegacySettingsRedirect />} />
<Route path="/media/:channel/settings/:panel?" element={<LegacySettingsRedirect />} />
</Routes>
</MemoryRouter>
</RuntimeContext.Provider>,
root,
));
const where = () => root.querySelector('[data-where]')?.getAttribute('data-where');
afterEach(() => { act(() => render(null, root)); localStorage.clear(); updateSettings.mockClear(); });
describe('偏好设置', () => {
it('holds every personal preference as a section of one page', () => {
Element.prototype.scrollIntoView = vi.fn();
paint('/me/settings');
expect([...root.querySelectorAll('.settings-section > h2')].map(h => h.textContent)).toEqual(['外观与主题', '书架', '影音浏览', '播放']);
// No save button anywhere: every control applies as it changes.
expect([...root.querySelectorAll('button')].some(b => /保存/.test(b.textContent ?? ''))).toBe(false);
act(() => { [...root.querySelectorAll<HTMLButtonElement>('.shelf-settings button')].find(b => b.textContent === '宽松')!.click(); });
expect(updateSettings).toHaveBeenCalledWith({ shelfDensity: 'comfortable' });
const density = root.querySelector<HTMLSelectElement>('select[aria-label="封面密度"]')!;
act(() => { density.value = 'comfortable'; density.dispatchEvent(new Event('change', { bubbles: true })); });
expect(readMediaPreferences('settings-test').density).toBe('comfortable');
expect(root.querySelector('a.settings-link')?.getAttribute('href')).toBe('/me/opds');
});
it('jumps to a section and keeps it in the address', () => {
const scroll = vi.fn();
Element.prototype.scrollIntoView = scroll;
paint('/me/settings');
act(() => { [...root.querySelectorAll<HTMLButtonElement>('.settings-jump button')].find(b => b.textContent === '播放')!.click(); });
expect(where()).toBe('/me/settings?section=playback');
expect(scroll).toHaveBeenCalled();
});
it.each([
['/me/theme', '/me/settings?section=theme'],
['/me/browse', '/me/settings?section=browse'],
['/me/playback', '/me/settings?section=playback'],
['/me/account', '/me/account'],
['/me/nothing', '/me'],
['/media/music/settings', '/me/settings'],
['/media/music/settings/browse', '/me/settings?section=browse'],
])('sends the old address %s to %s', (from, to) => {
Element.prototype.scrollIntoView = vi.fn();
paint(from);
expect(where()).toBe(to);
});
});
+6 -4
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { act, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { render } from '../src/shared/ui/render-root.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
@@ -58,8 +58,10 @@ afterEach(() => {
vi.restoreAllMocks();
});
function OpdsProbe() { return <p data-opds-return={useLocation().state?.returnTo} />; }
it('keeps the shelf entry points available and restores clear, settings and OPDS actions', async () => {
const value = mount(<ShelfPage />, runtime(), '/shelf?q=三体');
mount(<Routes><Route path="/shelf" element={<ShelfPage />} /><Route path="/me/opds" element={<OpdsProbe />} /></Routes>, runtime(), '/shelf?q=三体');
expect(root.textContent).toContain('书源');
expect(root.textContent).not.toContain('刷新');
expect(root.querySelector('button[aria-label^="书架设置"]')).not.toBeNull();
@@ -72,8 +74,8 @@ it('keeps the shelf entry points available and restores clear, settings and OPDS
await act(async () => settingsButton?.click());
expect(root.textContent).toContain('连接外部阅读器');
await act(async () => [...root.querySelectorAll('button')].find(button => button.textContent === '连接外部阅读器')?.click());
await vi.waitFor(() => expect(root.querySelector('dialog')).not.toBeNull());
expect(value.api.opdsCredentials).toHaveBeenCalled();
// The OPDS page of 我的, which returns to the shelf.
await vi.waitFor(() => expect(root.querySelector('[data-opds-return]')?.getAttribute('data-opds-return')).toBe('/shelf'));
});
it('collapses the server address and explains that sessions are remembered on login', () => {