mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
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:
1 parent
6b58ffd93c
commit
b8e5102669
38 files changed
+547
-335
No files matched your search
+2
-2
@@ -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 朗读」修改以下配置:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# 业务配置
|
||||
|
||||
管理员在「我的 → 管理 → 系统设置 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
|
||||
管理员在「我的 → 管理 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。
|
||||
|
||||
| 分类 | 可修改内容 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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` 都可打开扩展页;
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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、查询参数和状态参数只用于设计演示,不作为路由依据。
|
||||
@@ -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 项,并合并正在进行的相同目录请求;失败不缓存,下一次扫描创建新适配器重新读取。
|
||||
- 编辑凭据先校验新连接;失败保留旧凭据。新增、编辑和扫描仅管理员可操作。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。
|
||||
|
||||
媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 系统设置 → 服务配置」的“扫描设置”中:
|
||||
媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 服务配置」的“扫描设置”中:
|
||||
|
||||
- 自动扫描新文件(默认开):每小时检查一次所有媒体库,未变化的文件沿用上次的技术信息。
|
||||
- 刮削封面与简介(默认关):扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料、也从未刮削过的作品发起一次批量刮削;未匹配的作品不会每次重试。
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
|
||||
|
||||
管理员打开「我的 → 系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
管理员打开「我的 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。
|
||||
|
||||
详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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>; }
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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('管理员');
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user