From b8e51026697e691a0ac10fe0bd3e89c3864077c9 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sat, 31 Oct 2026 15:12:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=88=91=E7=9A=84=20opens=20every?= =?UTF-8?q?=20row=20as=20a=20page;=20preferences=20on=20one=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我的 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 --- docs/api.md | 4 +- docs/architecture.md | 6 +- docs/business-settings.zh-CN.md | 2 +- docs/configuration.en.md | 8 +- docs/configuration.zh-CN.md | 8 +- docs/frontend-react-migration.md | 2 +- docs/getting-started.en.md | 6 +- docs/getting-started.zh-CN.md | 6 +- docs/media-navigation.md | 4 +- docs/media-openlist.md | 2 +- docs/media-user-guide.zh-CN.md | 4 +- docs/opds-webdav.zh-CN.md | 8 +- docs/ui.md | 1 + docs/user-guide.zh-CN.md | 6 +- web/src/app/router/lazy-pages.tsx | 7 + web/src/app/router/routes.tsx | 22 ++- web/src/app/shell.css | 1 - .../features/account/pages/AccountPages.tsx | 27 ++++ web/src/features/account/pages/AdminPages.tsx | 33 +++++ web/src/features/account/pages/MinePage.tsx | 44 +++--- .../features/account/pages/SettingsPage.tsx | 97 +++++++++++++ web/src/features/account/styles/mine.css | 54 ++++++++ .../media/components/playback-settings.tsx | 4 +- .../features/media/components/settings.tsx | 130 +++++------------- .../media/components/theme-settings.tsx | 6 +- .../media/pages/MediaSettingsPage.tsx | 43 ------ .../features/media/styles/presentation.css | 11 +- web/src/features/media/styles/v2.css | 8 +- web/src/features/shelf/pages/ShelfPage.tsx | 8 +- web/src/ui/icon.tsx | 3 +- web/src/ui/opds-access.tsx | 14 +- web/src/ui/shelf-settings.tsx | 30 ++-- web/src/ui/system-settings.tsx | 103 +++++++++----- web/test/media-settings.test.tsx | 48 ++----- web/test/mine-page.test.tsx | 26 ++-- web/test/react-router-routes.test.tsx | 12 +- web/test/settings-page.test.tsx | 74 ++++++++++ web/test/shelf-login-parity.test.tsx | 10 +- 38 files changed, 547 insertions(+), 335 deletions(-) create mode 100644 web/src/features/account/pages/AccountPages.tsx create mode 100644 web/src/features/account/pages/AdminPages.tsx create mode 100644 web/src/features/account/pages/SettingsPage.tsx delete mode 100644 web/src/features/media/pages/MediaSettingsPage.tsx create mode 100644 web/test/settings-page.test.tsx diff --git a/docs/api.md b/docs/api.md index 1468a6a..1e93b9f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 朗读」修改以下配置: | 配置项 | 说明 | | --- | --- | diff --git a/docs/architecture.md b/docs/architecture.md index 3689ab4..710f9c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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,理由不是「谁更好」而是**谁知道得更多**: diff --git a/docs/business-settings.zh-CN.md b/docs/business-settings.zh-CN.md index 8015761..53c1525 100644 --- a/docs/business-settings.zh-CN.md +++ b/docs/business-settings.zh-CN.md @@ -1,6 +1,6 @@ # 业务配置 -管理员在「我的 → 管理 → 系统设置 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。 +管理员在「我的 → 管理 → 服务配置」编辑实例业务配置,通过「配置分类」切换各分类。普通用户无法读取或修改服务凭据。 | 分类 | 可修改内容 | | --- | --- | diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 1b03823..f2ae3a2 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -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). diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 046a280..7ae5742 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.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)。 diff --git a/docs/frontend-react-migration.md b/docs/frontend-react-migration.md index 7dba968..867bd0a 100644 --- a/docs/frontend-react-migration.md +++ b/docs/frontend-react-migration.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` 都可打开扩展页; diff --git a/docs/getting-started.en.md b/docs/getting-started.en.md index d662414..91bb454 100644 --- a/docs/getting-started.en.md +++ b/docs/getting-started.en.md @@ -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://: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). diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index b0dae08..e552e1a 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.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)。 diff --git a/docs/media-navigation.md b/docs/media-navigation.md index 81489e5..334912b 100644 --- a/docs/media-navigation.md +++ b/docs/media-navigation.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、查询参数和状态参数只用于设计演示,不作为路由依据。 diff --git a/docs/media-openlist.md b/docs/media-openlist.md index 615bdca..bbbe088 100644 --- a/docs/media-openlist.md +++ b/docs/media-openlist.md @@ -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 项,并合并正在进行的相同目录请求;失败不缓存,下一次扫描创建新适配器重新读取。 - 编辑凭据先校验新连接;失败保留旧凭据。新增、编辑和扫描仅管理员可操作。 diff --git a/docs/media-user-guide.zh-CN.md b/docs/media-user-guide.zh-CN.md index e179f88..71df38f 100644 --- a/docs/media-user-guide.zh-CN.md +++ b/docs/media-user-guide.zh-CN.md @@ -13,7 +13,7 @@ 扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。 -媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 系统设置 → 服务配置」的“扫描设置”中: +媒体库管理页顶部有“扫描与刮削”和“新建媒体库”,列表显示全部媒体库(与从哪个频道进入无关)。每个媒体库卡片显示缺失文件数和最近一次扫描:完成时间、进行中的进度(已处理百分比)和“取消”,或失败原因与“重试”“查看详情”;从未扫描的库显示“开始扫描”。卡片的 ⋯ 菜单提供编辑媒体库、访问权限和扫描记录。“扫描与元数据”是全部媒体库共用的四个开关,也出现在「我的 → 服务配置」的“扫描设置”中: - 自动扫描新文件(默认开):每小时检查一次所有媒体库,未变化的文件沿用上次的技术信息。 - 刮削封面与简介(默认关):扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料、也从未刮削过的作品发起一次批量刮削;未匹配的作品不会每次重试。 @@ -75,7 +75,7 @@ 本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。 -管理员打开「我的 → 系统设置 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。 +管理员打开「我的 → 服务配置」,在 TMDB 和 MusicBrainz 分类中配置令牌、API Key 或应用标识,保存后生效。管理员在作品菜单中匹配资料,审阅候选后确认;也可从「我的 → 扫描与刮削」的“刮削”标签发起批量任务并重试未完成项。来源网络、凭据或封面下载失败会单独提示。 详情的资源信息菜单可查看文件与技术信息、管理版本。手工资料、作品归属、版本拆分或合并会在重扫时保留,不修改原文件。移动版本不转移收藏,也不复制原作品的人工资料;更改剧集、季和单集关系后需重新确认受影响的在线匹配。 diff --git a/docs/opds-webdav.zh-CN.md b/docs/opds-webdav.zh-CN.md index 58c9a83..5e30e7e 100644 --- a/docs/opds-webdav.zh-CN.md +++ b/docs/opds-webdav.zh-CN.md @@ -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 ``` diff --git a/docs/ui.md b/docs/ui.md index a0d6cd8..48d08dc 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -858,6 +858,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - 主题在应用壳层统一应用(`ThemeController` 写 ``)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--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=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。 diff --git a/docs/user-guide.zh-CN.md b/docs/user-guide.zh-CN.md index 7c84ec5..dd27dd3 100644 --- a/docs/user-guide.zh-CN.md +++ b/docs/user-guide.zh-CN.md @@ -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)。 diff --git a/web/src/app/router/lazy-pages.tsx b/web/src/app/router/lazy-pages.tsx index 757f201..22d8107 100644 --- a/web/src/app/router/lazy-pages.tsx +++ b/web/src/app/router/lazy-pages.tsx @@ -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 ; } export function MediaLibrariesPage() { return ; } @@ -56,3 +60,6 @@ export function SourceEditorPage() { return ; } export function StatsPage() { return ; } export function SourceExtensionPage() { return ; } +export function AdminUsersPage() { return ; } +export function AdminRegistrationPage() { return ; } +export function AdminServicesPage() { return ; } diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx index f9dc924..d6558b7 100644 --- a/web/src/app/router/routes.tsx +++ b/web/src/app/router/routes.tsx @@ -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: }, { path: 'book/:bookId', element: }, // 我的 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: , children: [ { index: true, element: }, - // Before :panel, which would otherwise take it as a settings panel name. + { path: 'settings', element: }, { path: 'stats', element: }, - { path: ':panel', element: }, + { path: 'account', element: }, + { path: 'opds', element: }, + { path: 'admin/users', element: }, + { path: 'admin/registration', element: }, + { path: 'admin/services', element: }, + { path: ':panel', element: }, ] }, { path: 'media', element: , children: [ { index: true, element: }, @@ -72,13 +80,13 @@ export const routes = [{ { path: ':channel/favorites', element: }, { path: ':channel/queue', element: }, // 影音设置 is gone; its panels live in 我的. - { path: ':channel/settings', element: }, + { path: ':channel/settings', element: }, { path: ':channel/settings/libraries', element: }, { path: ':channel/settings/libraries/new', element: }, { path: ':channel/settings/libraries/:libraryId/edit', element: }, { path: ':channel/settings/libraries/:libraryId/permissions', element: }, { path: ':channel/settings/tasks', element: }, - { path: ':channel/settings/:panel', element: }, + { path: ':channel/settings/:panel', element: }, { path: ':channel/folders/:libraryId?', element: }, { path: 'audiobook/narrators', element: }, { path: 'audiobook/narrators/:narrator/works/:workId', element: }, diff --git a/web/src/app/shell.css b/web/src/app/shell.css index c31292b..d1cc937 100644 --- a/web/src/app/shell.css +++ b/web/src/app/shell.css @@ -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; } } diff --git a/web/src/features/account/pages/AccountPages.tsx b/web/src/features/account/pages/AccountPages.tsx new file mode 100644 index 0000000..fb80bef --- /dev/null +++ b/web/src/features/account/pages/AccountPages.tsx @@ -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 ; + return + + ; +} + +/** + * 外部阅读器 (/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 + { void runtime.api.signOut(); }} /> + ; +} diff --git a/web/src/features/account/pages/AdminPages.tsx b/web/src/features/account/pages/AdminPages.tsx new file mode 100644 index 0000000..2de6382 --- /dev/null +++ b/web/src/features/account/pages/AdminPages.tsx @@ -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 ; + return {children}; +} + +export function AdminUsersPage() { + const { api } = useRuntime(); + return ; +} + +export function AdminRegistrationPage() { + const { api } = useRuntime(); + return ; +} + +export function AdminServicesPage() { + const { api } = useRuntime(); + return
; +} diff --git a/web/src/features/account/pages/MinePage.tsx b/web/src/features/account/pages/MinePage.tsx index beca82e..e34c4dc 100644 --- a/web/src/features/account/pages/MinePage.tsx +++ b/web/src/features/account/pages/MinePage.tsx @@ -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 && } {admin && - setSheet('system')} /> + + + }
- - setSheet('shelf')} /> - - + - setSheet('sign-out')} /> + + setSignOutOpen(true)} />
- {/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */} - {sheet === 'shelf' && { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />} - {sheet === 'opds' && { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />} - {sheet === 'system' && setSheet(null)} />} - {sheet === 'sign-out' && setSheet(null)} + onCancel={() => setSignOutOpen(false)} onConfirm={() => { setSigningOut(true); void runtime.api.signOut().finally(() => setSigningOut(false)); }} />} ; } diff --git a/web/src/features/account/pages/SettingsPage.tsx b/web/src/features/account/pages/SettingsPage.tsx new file mode 100644 index 0000000..ba7f13e --- /dev/null +++ b/web/src/features/account/pages/SettingsPage.tsx @@ -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()); + 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[0]) => { void runtime.updateSettings(value); }; + + return +
+ + +
+

外观与主题

+ +
+ +
+

书架

+ + + 连接外部阅读器通过 OPDS,让其他阅读器只读访问你的书架 + + +
+ +
+

影音浏览

+

仅影响影音页面,在当前设备为此服务器和账号保存

+ +
+ +
+

播放

+ navigate('/media/video/player')} /> +
+
+
; +} + +/** + * 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 ; + if (panel === 'account') return ; + return ; +} diff --git a/web/src/features/account/styles/mine.css b/web/src/features/account/styles/mine.css index 7aca5b9..a7af97e 100644 --- a/web/src/features/account/styles/mine.css +++ b/web/src/features/account/styles/mine.css @@ -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; } diff --git a/web/src/features/media/components/playback-settings.tsx b/web/src/features/media/components/playback-settings.tsx index 0df0624..2f71363 100644 --- a/web/src/features/media/components/playback-settings.tsx +++ b/web/src/features/media/components/playback-settings.tsx @@ -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){const next={...value,...patch};try{savePlaybackPreferences(scope,next);setValue(next);setError('');}catch{setError('无法保存播放偏好,请检查设备存储。');}} - return

播放偏好

+ return

默认倍速在结束后再次开始播放时使用。连续播放适用于网页播放器,原生播放器使用其自身的队列设置。

{error&&

{error}

} - {player.active&&<>

当前播放

{player.title}{onPlayback&&}

睡眠定时

} + {player.active&&<>

当前播放

{player.title}{onPlayback&&}

睡眠定时

}
; } diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx index 0bf3fac..5ce500e 100644 --- a/web/src/features/media/components/settings.tsx +++ b/web/src/features/media/components/settings.tsx @@ -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 = { - 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) => 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) { 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) { - setDraft({ ...draft, ...patch }); - setSaved(false); - } + return
+ + + + + {error &&

{error}

} +
; +} - return
-
- -

{SETTINGS_TITLES[panel]}

-
- {leaving && setLeaving(false)} - onConfirm={() => { setLeaving(false); setDraft({ ...preferences }); onBack(); }} />} - {panel === 'theme' && } - {panel === 'browse' &&
{ event.preventDefault(); save(); }}> -

仅影响影音页面,在当前设备为此服务器和账号保存

- - - - -
- - -
- {saved &&

影音偏好已保存

} - {error &&

{error}

} -
} - {panel === 'account' && account &&
- -

{account.displayName || account.username}

-

@{account.username} · {account.role === 'admin' ? '管理员' : '普通用户'}

-

共享作品资料,收藏、队列和播放进度按账号隔离

-
当前服务器
{account.server || window.location.origin}
-
} - {panel === 'playback' && } +/** 账号与连接: who is signed in and to which server. Never shows tokens. */ +export function MediaAccountPanel({ account }: { account: MediaAccount }) { + return
+ +

{account.displayName || account.username}

+

@{account.username} · {account.role === 'admin' ? '管理员' : '普通用户'}

+

共享作品资料,收藏、队列和播放进度按账号隔离

+
当前服务器
{account.server || window.location.origin}
; } diff --git a/web/src/features/media/components/theme-settings.tsx b/web/src/features/media/components/theme-settings.tsx index 43cc39f..3f7757c 100644 --- a/web/src/features/media/components/theme-settings.tsx +++ b/web/src/features/media/components/theme-settings.tsx @@ -92,12 +92,12 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string; :
明暗 save({ mode })} />
} -

应用主题

+

应用主题

{appThemes.map(theme => chooseTheme(theme.id)} />)}
-

阅读主题

+

阅读主题

save({ reading: 'app' })} /> save({ reading: 'gray' })} /> @@ -105,7 +105,7 @@ export function MediaThemeSettings({ scope, onReaderSettings }: { scope: string;

黑白灰只用于阅读器,可以与应用主题分开设置;浅色和夜间跟随应用的明暗。主题偏好按服务器和账号保存在此设备。

{onReaderSettings && <> -

阅读纸色

+

阅读纸色

{ onReaderSettings(patch); setSaved(true); setError(''); }} />

纸色只改阅读页的底色和文字,工具栏仍用阅读主题;也可在阅读页的“外观”里切换。纸色按设备保存。

} diff --git a/web/src/features/media/pages/MediaSettingsPage.tsx b/web/src/features/media/pages/MediaSettingsPage.tsx deleted file mode 100644 index ecd96ef..0000000 --- a/web/src/features/media/pages/MediaSettingsPage.tsx +++ /dev/null @@ -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 ; - - return - navigate('/me')} - onPlayback={() => navigate(`/media/${channel}/player`)} - onReaderSettings={patch => { void runtime.updateSettings(patch); }} - /> - ; -} - -/** Old links to the media settings home and its panels: the panels now live in 我的. */ -export function LegacyMediaSettingsRedirect() { - const { panel } = useParams(); - return ; -} diff --git a/web/src/features/media/styles/presentation.css b/web/src/features/media/styles/presentation.css index 893fbec..0977eae 100644 --- a/web/src/features/media/styles/presentation.css +++ b/web/src/features/media/styles/presentation.css @@ -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)} diff --git a/web/src/features/media/styles/v2.css b/web/src/features/media/styles/v2.css index a43fd71..3cbd843 100644 --- a/web/src/features/media/styles/v2.css +++ b/web/src/features/media/styles/v2.css @@ -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; } diff --git a/web/src/features/shelf/pages/ShelfPage.tsx b/web/src/features/shelf/pages/ShelfPage.tsx index c972ae3..680cc3f 100644 --- a/web/src/features/shelf/pages/ShelfPage.tsx +++ b/web/src/features/shelf/pages/ShelfPage.tsx @@ -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(null); const [groupsOpen, setGroupsOpen] = useState(false); const [actionError, setActionError] = useState(''); @@ -177,8 +176,7 @@ export function ShelfPage() { {cards.length > 0 &&
{cards.map(book => )}
} {query.data && pages > 1 && go({ page: next })} />}
- {settingsOpen && { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); setOpdsOpen(true); }} />} - {opdsOpen && { void runtime.api.signOut(); }} onClose={() => { setOpdsOpen(false); setSettingsOpen(true); }} />} + {settingsOpen && { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); navigate('/me/opds', { state: { returnTo: '/shelf' } }); }} />} {groupsOpen && undefined } satisfies Dialog} destructive="" onClose={answer => { setGroupsOpen(false); if (typeof answer !== 'string') return; diff --git a/web/src/ui/icon.tsx b/web/src/ui/icon.tsx index 24e86dc..d98b2bc 100644 --- a/web/src/ui/icon.tsx +++ b/web/src/ui/icon.tsx @@ -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; diff --git a/web/src/ui/opds-access.tsx b/web/src/ui/opds-access.tsx index 8ca9107..3d37d13 100644 --- a/web/src/ui/opds-access.tsx +++ b/web/src/ui/opds-access.tsx @@ -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([]), [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 -
+ return

通过 OPDS 1.2 只读目录访问当前账号书架中的文件书籍。章节书和目录漫画暂不发布

@@ -47,6 +50,5 @@ export function OpdsAccess({ api, onClose, onSignedOut }: { api: ReaderApi; onCl {items.map(item =>
{item.name}到期:{new Date(item.expiresAt).toLocaleDateString()}
)} -
- ; +
; } diff --git a/web/src/ui/shelf-settings.tsx b/web/src/ui/shelf-settings.tsx index eee378b..2e91c74 100644 --- a/web/src/ui/shelf-settings.tsx +++ b/web/src/ui/shelf-settings.tsx @@ -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): JSX.Element { return ( - + <> 封面大小 onPatch({ shelfSort: value })} /> + + ); +} + +export function ShelfSettingsSheet({ + settings, + onPatch, + onClose, + onOpenExternalReader, +}: ShelfSettingsOptions): JSX.Element { + return ( + + 外部阅读器
通过 OPDS,让其他阅读器只读访问你的书架
diff --git a/web/src/ui/system-settings.tsx b/web/src/ui/system-settings.tsx index f0013ea..0045d39 100644 --- a/web/src/ui/system-settings.tsx +++ b/web/src/ui/system-settings.tsx @@ -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([]),[settings,setSettings]=useState(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(null),[confirmation,setConfirmation]=useState<{text:string;run:()=>Promise}|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,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
- +/** + * 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(load: () => Promise) { + const [data, setData] = useState(null); + const [busy, setBusy] = useState(false), [error, setError] = useState(''), [message, setMessage] = useState(''); + const [confirmation, setConfirmation] = useState<{ text: string; run: () => Promise } | null>(null); + async function work(action: () => Promise, 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 = <> - {confirmation && setConfirmation(null)} onConfirm={()=>{setConfirmation(null);void work(confirmation.run);}} />} - {tab==='users' ? <> -
setQuery(e.currentTarget.value)} />
- {form &&
{e.preventDefault();void work(async()=>{await api.adminCreateUser({username:username.trim(),password,displayName:displayName.trim(),role});setForm(false);setUsername('');setPassword('');setDisplayName('');setRole('member');},'用户已创建');}}> - - - - - -
} - {reset &&
{e.preventDefault();void work(async()=>{await api.adminResetPassword(reset.id,password);setReset(null);setPassword('');},'密码已重置,旧会话已失效');}}>

重置 {reset.username} 的密码,所有旧会话将立即失效。

} -

共 {users.length} 个账号

    {users.filter(user=>(user.username+' '+user.displayName).toLowerCase().includes(query.toLowerCase())).map(user=>
  • -
    {user.displayName} @{user.username}{user.id===self?' · 当前账号':''}
    -

    {user.role==='admin'?'管理员':'普通用户'} · {user.disabled?'已停用':'正常'}

    - {user.id!==self &&
    } -
  • )}
- : tab==='business'?:settings && <> - + {confirmation && 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(null), [query, setQuery] = useState(''); + const self = api.currentSession()?.user.id; + return
+ {notice} +
setQuery(e.currentTarget.value)} />
+ {form &&
{ e.preventDefault(); void work(async () => { await api.adminCreateUser({ username: username.trim(), password, displayName: displayName.trim(), role }); setForm(false); setUsername(''); setPassword(''); setDisplayName(''); setRole('member'); }, '用户已创建'); }}> + + + + + +
} + {reset &&
{ e.preventDefault(); void work(async () => { await api.adminResetPassword(reset.id, password); setReset(null); setPassword(''); }, '密码已重置,旧会话已失效'); }}>

重置 {reset.username} 的密码,所有旧会话将立即失效。

} +

共 {users.length} 个账号

+
    {users.filter(user => (user.username + ' ' + user.displayName).toLowerCase().includes(query.toLowerCase())).map(user =>
  • +
    {user.displayName} @{user.username}{user.id === self ? ' · 当前账号' : ''}
    +

    {user.role === 'admin' ? '管理员' : '普通用户'} · {user.disabled ? '已停用' : '正常'}

    + {user.id !== self &&
    + + + +
    } +
  • )}
+
; +} + +export function RegistrationManagement({ api }: { api: ReaderApi }) { + const { data: settings, busy, work, confirm, notice } = useAdminWork(() => api.registrationSettings()); + const [label, setLabel] = useState(''), [maxUses, setMaxUses] = useState(1), [days, setDays] = useState(7), [code, setCode] = useState(''); + return
+ {notice} + {settings && <> +

关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。

-
创建邀请码
{e.preventDefault();void work(async()=>{setCode((await api.createInvite({label,maxUses,days})).code);},'邀请码已创建');}}>
- {code && } -
    {settings.invites.map(invite=>
  • {invite.label||'未备注邀请码'}

    已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled?'已停用':invite.expiresAt<=Date.now()?'已过期':invite.usedCount>=invite.maxUses?'已用完':'可用'}

    有效至 {new Date(invite.expiresAt).toLocaleString()}

    {!invite.disabled && }
  • )}
+
创建邀请码
{ e.preventDefault(); void work(async () => { setCode((await api.createInvite({ label, maxUses, days })).code); }, '邀请码已创建'); }}>
+ {code && } +
    {settings.invites.map(invite =>
  • {invite.label || '未备注邀请码'}

    已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled ? '已停用' : invite.expiresAt <= Date.now() ? '已过期' : invite.usedCount >= invite.maxUses ? '已用完' : '可用'}

    有效至 {new Date(invite.expiresAt).toLocaleString()}

    {!invite.disabled && }
  • )}
} -
; +
; } diff --git a/web/test/media-settings.test.tsx b/web/test/media-settings.test.tsx index 14105d1..c743be8 100644 --- a/web/test/media-settings.test.tsx +++ b/web/test/media-settings.test.tsx @@ -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('[aria-label="返回我的"]')!.click()); -it('saves explicit changes and reports storage errors without applying them',async()=>{ - const onSaved=vi.fn(); - await act(async()=>render(,root)); +it('applies browse preferences as they change, and keeps the stored value when storage fails',async()=>{ + await act(async()=>render(,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('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(,root)); + const speed=vi.fn(),sleep=vi.fn(),onPlayback=vi.fn(); + await act(async()=>render(,root)); expect(root.querySelector('[aria-label="默认倍速"]')).not.toBeNull();expect(root.textContent).toContain('连续播放'); const current=root.querySelector('[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(,root)); + await act(async()=>render(,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(,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(,root)); - back(); - expect(root.querySelector('[role=alertdialog]')).toBeNull();expect(onBack).toHaveBeenCalledTimes(1); -}); diff --git a/web/test/mine-page.test.tsx b/web/test/mine-page.test.tsx index bca1fac..67e28c6 100644 --- a/web/test/mine-page.test.tsx +++ b/web/test/mine-page.test.tsx @@ -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('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('dialog[aria-label="书架设置"]'); - expect(sheet()).toBeNull(); - act(() => { [...root.querySelectorAll('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('[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('管理员'); }); diff --git a/web/test/react-router-routes.test.tsx b/web/test/react-router-routes.test.tsx index 0b0625d..43e43b7 100644 --- a/web/test/react-router-routes.test.tsx +++ b/web/test/react-router-routes.test.tsx @@ -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'], diff --git a/web/test/settings-page.test.tsx b/web/test/settings-page.test.tsx new file mode 100644 index 0000000..e980342 --- /dev/null +++ b/web/test/settings-page.test.tsx @@ -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

; } +const paint = (entry: string) => act(() => render( + + + + } /> + } /> + } /> + } /> + } /> + + + , + 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('.shelf-settings button')].find(b => b.textContent === '宽松')!.click(); }); + expect(updateSettings).toHaveBeenCalledWith({ shelfDensity: 'comfortable' }); + const density = root.querySelector('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('.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); + }); +}); diff --git a/web/test/shelf-login-parity.test.tsx b/web/test/shelf-login-parity.test.tsx index 6b40999..b46ee26 100644 --- a/web/test/shelf-login-parity.test.tsx +++ b/web/test/shelf-login-parity.test.tsx @@ -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

; } + it('keeps the shelf entry points available and restores clear, settings and OPDS actions', async () => { - const value = mount(, runtime(), '/shelf?q=三体'); + mount(} />} />, 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', () => {