Compare commits

...

7 Commits

Author SHA1 Message Date
truewhile e961969207 优化 2026-10-03 18:25:13 +08:00
truewhile b8b02cb3a7 优化 2026-10-03 14:21:07 +08:00
truewhile 60cfad47e5 优化 2026-10-03 14:05:24 +08:00
truewhile 47c8bfdd6c bug处理 2026-10-03 13:38:18 +08:00
truewhile ce3baee8f1 优化 2026-10-02 23:54:37 +08:00
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
truewhile c4e9926352 优化 2026-10-02 18:44:07 +08:00
32 changed files with 3032 additions and 453 deletions
+3 -2
View File
@@ -33,13 +33,13 @@
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
**阅读器偏好按账号同步(跨设备)**
@@ -97,6 +97,7 @@
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
//
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
// 这里只钉住契约与安全边界,避免把测试绑到网络。
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(container)
post := func(body string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w.Code
}
cases := []struct {
name string
body string
}{
{"缺少 url", `{"book_id":"x"}`},
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
}
for _, c := range cases {
if code := post(c.body); code != http.StatusBadRequest {
t.Fatalf("%s:应 400,得到 %d", c.name, code)
}
}
}
+30 -1
View File
@@ -40,6 +40,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
g.POST("/comments/open", readerOpenCommentHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
@@ -249,6 +252,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
BookID string `json:"book_id" binding:"required"`
URL string `json:"url" binding:"required"`
Title string `json:"title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
}
// readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
@@ -333,13 +360,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -0,0 +1,149 @@
package reader
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 本文件:书籍类型兜底的回归测试。
//
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
// 整个阅读页是一屏 URL(用户截图里的现象)。
//
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
func audioTestSourceJSON(t *testing.T, base string) string {
t.Helper()
return `{
"bookSourceName": "听书测试源",
"bookSourceType": 1,
"bookSourceUrl": "` + base + `",
"ruleContent": {"content": "$.data.url"}
}`
}
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
// 并把纠正后的类型写回书架记录。
func TestAudioBookTypeFallbackBySource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
// 复刻详情页写死的 origin_type=0
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID,
Origin: srv.URL,
OriginName: "听书测试源",
OriginType: 0,
BookURL: srv.URL + "/book/1",
}, "测试听书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "audio" {
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
out.Type, out.Content)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
}
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
if err != nil {
t.Fatalf("代理地址校验失败: %v", err)
}
if raw != "https://cdn.example.com/a.m4a" {
t.Fatalf("代理还原地址 = %q", raw)
}
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
// book.type 也一直是错的(书源 JS 会读它分支)。
reloaded, err := svc.GetBook(ctx, book.ID)
if err != nil {
t.Fatalf("重读书籍失败: %v", err)
}
if reloaded.Type != 1 {
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
}
}
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
src := `{
"bookSourceName": "声明文本的音频源",
"bookSourceType": 1,
"bookSourceUrl": "` + srv.URL + `",
"ruleContent": {"content": "@js:book.type = 8; result"}
}`
sourceID := prepareLoginSource(t, svc, src)
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
OriginType: 0, BookURL: srv.URL + "/book/1",
}, "声明文本", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
}
}
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
func trackQueryValue(t *testing.T, raw, key string) string {
t.Helper()
idx := strings.Index(raw, key+"=")
if idx < 0 {
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
}
rest := raw[idx+len(key)+1:]
if end := strings.Index(rest, "&"); end >= 0 {
rest = rest[:end]
}
return rest
}
+30 -1
View File
@@ -64,6 +64,9 @@ type BrowserPage struct {
Title string `json:"title"`
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
Mode string `json:"mode"`
// Seq 登记序号(单调递增)。待办表可能同时存多个页面,前端按它取最新的
// 那个展示——不能依赖返回顺序,map 遍历是随机的。
Seq int64 `json:"seq"`
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
PageURL string `json:"page_url"`
// Refetch 见 rule.BrowserTask.Refetch。
@@ -83,8 +86,10 @@ type BrowserPageSnapshot struct {
}
// pendingBrowser 一个待用户完成的页面。
// seq 为登记序号(越大越新,见 ReaderService.browserSeq),供前端挑选最新页面。
type pendingBrowser struct {
id string
seq int64
sourceID string
sourceURL string
userID string
@@ -180,6 +185,8 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
s.browserPending = map[string]*pendingBrowser{}
}
s.pruneBrowsersLocked()
s.browserSeq++
entry.seq = s.browserSeq
s.browserPending[id] = entry
s.browserMu.Unlock()
@@ -716,6 +723,10 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
if e.sourceID != sourceID {
continue
}
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
if e.finished {
continue
}
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
if e.userID != "" && userID != "" && e.userID != userID {
continue
@@ -726,14 +737,20 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
}
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
//
// 交付后立刻把条目移出待办表:open 模式(java.startBrowser)没有别的清理
// 路径,留着会一直躺到 TTL 到期——光遇聚合的「❇️ 更新书源」就是这么在
// 15 分钟内反复串到其它按钮上的。
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
entry := s.lookupBrowser(id)
if entry == nil {
return errors.New("页面已过期或已完成")
}
// 注意锁序:browserMu 必须在 entry.mu 之外获取(pruneBrowsersLocked 是
// browserMu → entry.mu),这里不能持着 entry.mu 去等 browserMu。
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.finished {
entry.mu.Unlock()
return errors.New("该页面已完成")
}
if cancelled {
@@ -749,6 +766,9 @@ func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, c
if entry.done != nil {
close(entry.done)
}
entry.mu.Unlock()
s.dropBrowser(id)
return nil
}
@@ -785,6 +805,14 @@ func (s *ReaderService) pruneBrowsersLocked() {
}
}
// browserPageOf 生成前端展示用的描述(自行加锁)。
// registerBrowser 之后需要把页面直接回给调用方(如打开一条段评)时用它。
func (s *ReaderService) browserPageOf(e *pendingBrowser) BrowserPage {
s.browserMu.Lock()
defer s.browserMu.Unlock()
return s.pageOfLocked(e)
}
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
target := e.request.URL
@@ -795,6 +823,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
ID: e.id,
Title: e.request.Title,
Mode: e.mode,
Seq: e.seq,
PageURL: s.browserPageURL(e.id),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
@@ -390,3 +390,46 @@ func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
t.Fatal("未解除阻塞")
}
}
// TestBrowserPanelOpenPageDroppedAfterResolve open 模式(java.startBrowser)
// 的页面在用户关闭后必须立刻离开待办表。
//
// 回归:这条日志只删除 wait 模式(awaitBrowser 的 defer),open 模式没有清理
// 路径,会一直躺到 15 分钟 TTL 到期。前端轮询到它就又把旧页面弹出来——光遇
// 聚合点过「❇️ 更新书源」后,接下来点「书源设置」「段评设置」都会跳到书源更新页。
func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
t.Fatalf("登记 open 页面失败: %v", err)
}
old := waitPending(t, svc, sourceID)
if old.Mode != browserModeOpen {
t.Fatalf("模式 = %q,期望 open", old.Mode)
}
// 用户关掉页面
if err := svc.ResolveBrowser(old.ID, readerTestUserID, "", "", true); err != nil {
t.Fatalf("关闭页面失败: %v", err)
}
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
t.Fatalf("关闭后待办未清理: %+v", pages)
}
// 再点别的按钮:待办表里只应剩新页面,且序号更大
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
t.Fatalf("登记新页面失败: %v", err)
}
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
if len(pages) != 1 || pages[0].Title != "光遇书源设置" {
t.Fatalf("待办表应只剩新页面: %+v", pages)
}
if pages[0].Seq <= old.Seq {
t.Fatalf("新页面 seq 应更大: %d <= %d", pages[0].Seq, old.Seq)
}
}
+492
View File
@@ -0,0 +1,492 @@
package reader
import (
"context"
"encoding/base64"
"fmt"
"net"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:正文里的「段评」锚点解析(对齐 legado 的段评机制)。
//
// legado 侧段评有两种落在正文里的形态:
//
// 1. 段落里的 <comment ident="评论地址" count="评论数" />:宿主(苹果/源阅分支)
// 直接把它解析成段落旁的评论气泡;
// 2. 「改版」宿主不认 <comment>,改由书源在正文规则里把它换成一张内嵌 SVG 图片,
// 图片地址后面再跟一段 JSON 配置:
// <img src="data:image/svg+xml;base64,…{"click":"showCmt('评论地址','平台','段评')","style":"text"}">
// legado 识别的是地址后面的这份配置(ReadBookActivity.oldClickImg),点击时把
// 配置里的 click/js 当 JS 执行(showCmt 内部再调 java.showBrowser 打开评论页)。
//
// MeBox 没有 Android 的 Canvas 排版,正文按行渲染,因此这里把两种形态都归一成
// 「某一行的段评气泡」:只摘掉标记本身,正文文字一个字都不动,并把评论地址 / 评论数
// 结构化下发给前端;点击气泡时前端复用宿主浏览器(带书源 Cookie)打开评论地址。
//
// 约定:但凡无法确定评论地址的图片,一律原样保留交给既有链路,绝不猜一个地址出来。
// ContentComment 正文里的一条段评锚点(随正文一起下发给前端)。
type ContentComment struct {
// Line 段落行号:Content 按 "\n" 拆分后的 0-based 下标。
Line int `json:"line"`
// Count 评论数(0 表示未知:前端只画一个不带数字的气泡)。
Count int `json:"count"`
// URL 评论地址(书源 showCmt 的第一个参数),前端点击时交给宿主浏览器打开。
URL string `json:"url"`
// Label 评论类型:段评 / 本章说,用作气泡提示。
Label string `json:"label,omitempty"`
// Block 独占一行的整块评论(章末「本章说」),前端按居中整块渲染。
Block bool `json:"block,omitempty"`
}
// commentTagRe 匹配正文里的 <comment …> 标签。属性顺序不定、可能自闭合,
// 也可能带一个配对结束标签(`<comment …>…</comment>`),一并吃掉。
var commentTagRe = regexp.MustCompile(`(?is)<comment\b[^>]*?/?>(?:</comment>)?`)
// 下面三条属性正则都允许单/双引号两种写法;捕获组 1 是双引号内容,2 是单引号内容。
var (
commentIdentRe = regexp.MustCompile(`(?is)\bident\s*=\s*(?:"([^"]*)"|'([^']*)')`)
commentCountRe = regexp.MustCompile(`(?is)\bcount\s*=\s*(?:"([^"]*)"|'([^']*)')`)
commentOnPressRe = regexp.MustCompile(`(?is)\bonPress\s*=\s*(?:"([^"]*)"|'([^']*)')`)
)
// commentJSURLRe 从 showCmt / showReadingBrowser / startBrowserDp 的调用里取第一个字符串参数。
// 苹果分支的 <comment onPress="java.showReadingBrowser('地址',…)" /> 就靠它取地址。
// RE2 不支持反向引用,双/单引号两种写法拆成两个捕获组。
var commentJSURLRe = regexp.MustCompile(`(?is)(?:showCmt|showReadingBrowser|startBrowserDp)\s*\(\s*(?:"([^"]*)"|'([^']*)')`)
// imgParamRe 宽容地提取图片地址后面 JSON 配置里的键值对。
//
// 不用 encoding/json 解析:光遇聚合的「本章说」把配置拼成 {'type':'qtbzs',"click":"…",'style':'FULL'}
// (单双引号混用),严格 JSON 解析会失败,而它又是合法 JS 对象字面量。
var imgParamRe = regexp.MustCompile(`(?is)["']?(click|js|style|type|width)["']?\s*:\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')`)
// svgCountRe 取内嵌段评 SVG 里画在气泡上的数字(书源把评论数直接画进了 SVG 文本)。
var svgCountRe = regexp.MustCompile(`(?is)<text[^>]*>\s*([0-9]+)\s*\+?\s*</text>`)
// wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。
var wrapMarkupRe = regexp.MustCompile(`(?is)</?(?:p|div|br|hr|h[1-6]|article|dd|dl)\b[^>]*>`)
// extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。
//
// 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。
func extractContentComments(content string) (string, []ContentComment) {
if content == "" || (!strings.Contains(content, "<comment") && !strings.Contains(content, "<img")) {
return content, nil
}
lines := strings.Split(content, "\n")
out := make([]string, len(lines))
comments := make([]ContentComment, 0, 4)
changed := false
for i, line := range lines {
text, cs := extractLineComments(line, i)
out[i] = text
if len(cs) > 0 {
changed = true
comments = append(comments, cs...)
}
}
if !changed {
return content, nil
}
return strings.Join(out, "\n"), comments
}
// extractLineComments 处理一行:返回去掉段评标记后的文字与该行上的段评。
func extractLineComments(line string, lineNo int) (string, []ContentComment) {
type marker struct {
start, end int
comment ContentComment
}
var markers []marker
for _, m := range commentTagRe.FindAllStringIndex(line, -1) {
if c, ok := parseCommentTag(line[m[0]:m[1]], lineNo); ok {
markers = append(markers, marker{m[0], m[1], c})
}
}
for _, tag := range scanImgTags(line) {
if c, ok := parseCommentImage(tag.src, lineNo); ok {
markers = append(markers, marker{tag.start, tag.end, c})
}
}
if len(markers) == 0 {
return line, nil
}
sort.Slice(markers, func(i, j int) bool { return markers[i].start < markers[j].start })
comments := make([]ContentComment, 0, len(markers))
var b strings.Builder
prev := 0
for _, mk := range markers {
if mk.start < prev { // 与前一个标记重叠(理论上不会出现),跳过避免重复删除
continue
}
b.WriteString(line[prev:mk.start])
prev = mk.end
c := mk.comment
// 摘掉标记后整行只剩标签/空白 → 说明评论独占一行(章末「本章说」)
c.Block = isBlankMarkup(line[:mk.start] + line[mk.end:])
comments = append(comments, c)
}
b.WriteString(line[prev:])
return b.String(), comments
}
// imgTagMatch 一行里一个 <img> 标签的位置与 src 值。
type imgTagMatch struct {
start, end int
src string
}
// scanImgTags 扫描一行里的所有 <img> 标签,返回标签位置与 src 值。
//
// 不能用 <img[^>]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成
// <img src="data:…,{"click":"showCmt('…','…')"}"> —— 属性值里的 JSON 又用了双引号,
// 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号,
// 只把「花括号之外遇到的引号」当作属性值的结束。
func scanImgTags(line string) []imgTagMatch {
var out []imgTagMatch
lower := strings.ToLower(line)
for i := 0; i < len(line); {
idx := strings.Index(lower[i:], "<img")
if idx < 0 {
break
}
start := i + idx
src, end, ok := scanImgTagSrc(line, start)
if !ok {
i = start + 4
continue
}
out = append(out, imgTagMatch{start: start, end: end, src: src})
i = end
}
return out
}
// scanImgTagSrc 从 <img 起始位置扫描出 src 属性值,并返回标签结束下标(含 '>')。
func scanImgTagSrc(line string, start int) (string, int, bool) {
lower := strings.ToLower(line)
i := start + 4 // 跳过 "<img"
for {
si := strings.Index(lower[i:], "src")
if si < 0 {
return "", 0, false
}
si += i
q := si + 3
for q < len(line) && isHTMLSpace(line[q]) {
q++
}
// 必须是 src= 而不是 srcset= 之类的近似字段
if q >= len(line) || line[q] != '=' {
i = si + 3
continue
}
q++
for q < len(line) && isHTMLSpace(line[q]) {
q++
}
if q >= len(line) || (line[q] != '"' && line[q] != '\'') {
i = si + 3
continue
}
quote := line[q]
valStart := q + 1
depth := 0
j := valStart
for ; j < len(line); j++ {
c := line[j]
switch {
case c == '{':
depth++
case c == '}' && depth > 0:
depth--
case c == quote && depth == 0:
// 花括号之外的引号才是属性值的结束
gt := strings.IndexByte(line[j:], '>')
if gt < 0 {
return "", 0, false
}
return line[valStart:j], j + gt + 1, true
}
}
return "", 0, false
}
}
// isHTMLSpace 判断 HTML 属性之间的空白(含换行/制表)。
func isHTMLSpace(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
// parseCommentTag 解析宿主侧的 <comment ident count /> 形式。
func parseCommentTag(tag string, lineNo int) (ContentComment, bool) {
rawURL := attrOf(commentIdentRe, tag)
if rawURL == "" {
rawURL = attrOf(commentOnPressRe, tag)
}
rawURL = extractCommentURL(rawURL)
if rawURL == "" {
return ContentComment{}, false
}
count, _ := strconv.Atoi(strings.TrimSpace(attrOf(commentCountRe, tag)))
return ContentComment{Line: lineNo, Count: count, URL: rawURL, Label: "段评"}, true
}
// parseCommentImage 解析「图片地址 + JSON 配置」形式的段评。
//
// 只把真正指向评论的气泡当段评:地址要么来自 showCmt/showReadingBrowser,
// 要么图片本身就是内嵌的段评 SVG。其它带 click 配置的图片保持原样,避免误伤。
func parseCommentImage(src string, lineNo int) (ContentComment, bool) {
src = strings.TrimSpace(src)
if src == "" {
return ContentComment{}, false
}
base, params := splitImgSrcParams(src)
if len(params) == 0 {
return ContentComment{}, false
}
action := params["click"]
if action == "" {
action = params["js"]
}
if action == "" {
return ContentComment{}, false
}
isCommentCall := strings.Contains(action, "showCmt") || strings.Contains(action, "showReadingBrowser")
if !isCommentCall && !strings.HasPrefix(base, "data:image/svg+xml") {
return ContentComment{}, false
}
rawURL := extractCommentURL(action)
if rawURL == "" {
return ContentComment{}, false
}
label := "段评"
if strings.Contains(action, "本章说") {
label = "本章说"
}
return ContentComment{
Line: lineNo,
Count: commentCountFromImage(base),
URL: rawURL,
Label: label,
}, true
}
// splitImgSrcParams 把图片地址拆成(地址, JSON 配置),没有配置时返回 (src, nil)。
//
// 对应 legado 的 AnalyzeUrl.paramPattern `\s*,\s*(?=\{)`:地址与配置之间用「逗号 + 花括号」分隔。
func splitImgSrcParams(src string) (string, map[string]string) {
for i := 0; i < len(src); i++ {
if src[i] != ',' {
continue
}
j := i + 1
for j < len(src) && (src[j] == ' ' || src[j] == '\t' || src[j] == '\n' || src[j] == '\r') {
j++
}
if j >= len(src) || src[j] != '{' {
continue
}
end := strings.LastIndexByte(src, '}')
if end <= j {
continue
}
if params := parseImgParams(src[j : end+1]); len(params) > 0 {
return strings.TrimSpace(src[:i]), params
}
}
return src, nil
}
// parseImgParams 宽容解析图片配置里的键值对(见 imgParamRe 的说明)。
func parseImgParams(raw string) map[string]string {
ms := imgParamRe.FindAllStringSubmatch(raw, -1)
if len(ms) == 0 {
return nil
}
out := make(map[string]string, len(ms))
for _, m := range ms {
val := m[2]
if val == "" {
val = m[3]
}
val = strings.ReplaceAll(val, `\"`, `"`)
val = strings.ReplaceAll(val, `\'`, `'`)
out[strings.ToLower(m[1])] = val
}
return out
}
// commentCountFromImage 从内嵌段评 SVG 里取评论数(书源把数字画进了 SVG 文本)。
// 取不到时返回 0,前端只画一个不带数字的气泡。
func commentCountFromImage(src string) int {
decoded, ok := decodeCommentDataURI(src)
if !ok {
return 0
}
m := svgCountRe.FindStringSubmatch(decoded)
if m == nil {
return 0
}
n, err := strconv.Atoi(m[1])
if err != nil || n < 0 {
return 0
}
return n
}
// decodeCommentDataURI 解出 data: URI 的文本内容(支持 base64 与明文两种)。
func decodeCommentDataURI(s string) (string, bool) {
if !strings.HasPrefix(s, "data:") {
return "", false
}
comma := strings.IndexByte(s, ',')
if comma < 0 {
return "", false
}
meta, payload := s[:comma], s[comma+1:]
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
b, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", false
}
return string(b), true
}
return payload, true
}
// extractCommentURL 从 showCmt/showReadingBrowser 调用或裸地址里取评论地址。
func extractCommentURL(js string) string {
js = strings.TrimSpace(js)
if js == "" {
return ""
}
if m := commentJSURLRe.FindStringSubmatch(js); m != nil {
return trimCommentURL(firstSubmatch(m))
}
if strings.HasPrefix(js, "http://") || strings.HasPrefix(js, "https://") {
return trimCommentURL(js)
}
return ""
}
// attrOf 取属性值,双引号优先、单引号兜底。
func attrOf(re *regexp.Regexp, s string) string {
m := re.FindStringSubmatch(s)
if m == nil {
return ""
}
return firstSubmatch(m)
}
// firstSubmatch 返回「双引号 / 单引号」两个捕获组里非空的那个。
func firstSubmatch(m []string) string {
if len(m) > 1 && m[1] != "" {
return m[1]
}
if len(m) > 2 {
return m[2]
}
return ""
}
// trimCommentURL 去空白并还原 HTML 实体。
// 苹果/源阅分支会把地址里的 & 转成 &amp;(paraForiOS),直接拿去请求会 404。
func trimCommentURL(raw string) string {
r := strings.NewReplacer(
"&quot;", `"`, "&#34;", `"`,
"&#39;", "'", "&apos;", "'",
"&lt;", "<", "&gt;", ">",
"&amp;", "&",
)
return strings.TrimSpace(r.Replace(raw))
}
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
func isBlankMarkup(s string) bool {
s = wrapMarkupRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&nbsp;", " ")
s = strings.ReplaceAll(s, "\u3000", " ")
return strings.TrimSpace(s) == ""
}
// ─── 打开段评(宿主浏览器承载) ─────────────────────────────────────────────
// OpenContentComment 在宿主浏览器里打开一条段评。
//
// 书源点击段评气泡时执行的是它自己的 showCmt(...):先 java.ajax 取评论页,
// 再 java.showBrowser 展示。MeBox 不跑书源 JS,而是把 showCmt 的第一个参数
// (评论地址)解析出来(见本文件),复用承载登录页的那套宿主浏览器打开:
// 服务端带书源 Cookie 抓取评论页,前端 iframe 展示。这样评论接口需要的登录态
// 仍然生效——前端直接 window.open 会以未登录身份访问。
//
// 返回的 BrowserPage 直接给前端渲染,不必走轮询(段评只需展示、无需回传 DOM)。
func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, rawURL, title string) (*BrowserPage, error) {
if s.repo == nil {
return nil, fmt.Errorf("阅读服务未就绪")
}
target, err := validateCommentURL(rawURL)
if err != nil {
return nil, err
}
book, err := s.repo.GetBook(ctx, bookID)
if err != nil || book == nil {
return nil, fmt.Errorf("书籍不存在或已移出书架")
}
sourceID := ""
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
sourceID = src.ID
}
if strings.TrimSpace(title) == "" {
title = "段评"
}
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
if err != nil {
return nil, err
}
page := s.browserPageOf(entry)
return &page, nil
}
// validateCommentURL 校验评论地址:只允许 http(s),并挡住内网地址。
//
// 段评地址来自书源正文,会带着书源登录态由服务端去抓;不做限制就等于给了一个
// 「以书源身份请求任意地址」的口子(SSRF)。
func validateCommentURL(raw string) (string, error) {
u := strings.TrimSpace(raw)
if u == "" {
return "", fmt.Errorf("评论地址为空")
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", fmt.Errorf("评论地址无效:%s", truncateForLog(u, 80))
}
if isPrivateHost(parsed.Hostname()) {
return "", fmt.Errorf("评论地址指向内网,已拒绝")
}
return u, nil
}
// isPrivateHost 判断主机名是否是回环/内网/链路本地地址。
func isPrivateHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if h == "" || h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
ip := net.ParseIP(h)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
}
+230
View File
@@ -0,0 +1,230 @@
package reader
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 段评解析单测:覆盖 legado 的两种正文形态(<comment /> 与「图片地址 + JSON 配置」),
// 以及「绝不能吞掉或破坏正文文字」这条硬约束。
// svgCommentSrc 按光遇聚合 createSvg 的写法拼一张带评论数气泡图:
// data:image/svg+xml;base64,<svg>,{"click":"showCmt('地址','平台','段评')","style":"text"}
//
// 注意第二个逗号后面直接跟 JSON,且 JSON 自身用双引号——它嵌在双引号属性里,
// 正是会打断普通正则的「非法 HTML」,解析必须扛得住。
func svgCommentSrc(t *testing.T, count int, click string, style string) string {
t.Helper()
svg := fmt.Sprintf(
`<svg xmlns='http://www.w3.org/2000/svg'><text x='1' y='2'>%d</text></svg>`, count)
b64 := base64.StdEncoding.EncodeToString([]byte(svg))
return fmt.Sprintf(`data:image/svg+xml;base64,%s,{"click":%q,"style":%q}`, b64, click, style)
}
func TestExtractContentCommentsLegacyTag(t *testing.T) {
content := `<p>第一段<comment ident="https://cdn.example.com/c/1" count="12" /></p>
<p>第二段</p>`
text, comments := extractContentComments(content)
if text != "<p>第一段</p>\n<p>第二段</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 0 || c.Count != 12 || c.URL != "https://cdn.example.com/c/1" || c.Label != "段评" {
t.Fatalf("解析结果不符:%+v", c)
}
if c.Block {
t.Fatalf("段落内的段评不应标记为整块:%+v", c)
}
}
func TestExtractContentCommentsIOSOnPress(t *testing.T) {
content := `<p>正文<comment count="7" onPress="java.showReadingBrowser('https://cdn.example.com/c/2?a=1&amp;b=2','番茄段评')"></comment></p>`
text, comments := extractContentComments(content)
if text != "<p>正文</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
// &amp; 必须还原成 &,否则请求评论接口会 404
if comments[0].URL != "https://cdn.example.com/c/2?a=1&b=2" || comments[0].Count != 7 {
t.Fatalf("解析结果不符:%+v", comments[0])
}
}
func TestExtractContentCommentsInlineImage(t *testing.T) {
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1&item_id=2','番茄','段评')", "text")
content := `<p>正文内容<img src="` + src + `"></p>
<p>下一段</p>`
text, comments := extractContentComments(content)
if text != "<p>正文内容</p>\n<p>下一段</p>" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 0 || c.Count != 3 || c.Label != "段评" {
t.Fatalf("解析结果不符:%+v", c)
}
// 地址里的 & 原样保留(JSON 里的 & 不是实体)
if c.URL != "https://v1.example.com/get_review?book_id=1&item_id=2" {
t.Fatalf("评论地址 = %q", c.URL)
}
}
func TestExtractContentCommentsChapterEndBlock(t *testing.T) {
// 章末「本章说」:单独一行,配置用单引号键、双引号值(合法 JS 对象字面量,非法 JSON)
line := `<img src="https://v1.example.com/chapter_review_svg?book_id=1&amp;img=1,` +
`{'type':'qtbzs',` +
`"click":"showCmt('https://v1.example.com/get_review?book_id=1','番茄','本章说' )",` +
`'style':'FULL'}">`
content := "<p>正文最后一段</p>\n" + line
text, comments := extractContentComments(content)
if text != "<p>正文最后一段</p>\n" {
t.Fatalf("正文被破坏:%q", text)
}
if len(comments) != 1 {
t.Fatalf("评论数 = %d,期望 1", len(comments))
}
c := comments[0]
if c.Line != 1 || c.Label != "本章说" || !c.Block {
t.Fatalf("解析结果不符:%+v", c)
}
if c.URL != "https://v1.example.com/get_review?book_id=1" {
t.Fatalf("评论地址 = %q", c.URL)
}
}
func TestExtractContentCommentsCountOver99(t *testing.T) {
// 书源把 >99 的评论数显示成 "99+",解析要取到 99
src := svgCommentSrc(t, 99, "showCmt('https://x.example.com/c','番茄','段评')", "text")
// 手工把文本改成 99+
src = strings.Replace(src, ">99<", ">99+<", 1)
_, comments := extractContentComments(`<p>x<img src="` + src + `"></p>`)
if len(comments) != 1 || comments[0].Count != 99 {
t.Fatalf("期望解析出 99,得到 %+v", comments)
}
}
// 没有任何段评标记时,正文必须逐字节不变(不能因为「看起来像 HTML」就动手)。
func TestExtractContentCommentsPreservesPlainContent(t *testing.T) {
content := `<p>普通段落</p>
<p>带插图的段落<img src="https://cdn.example.com/a.jpg"></p>
<p>带 onclick 图但非段评<img src="https://cdn.example.com/b.jpg,{'click':'openPhoto(1)'}"></p>`
text, comments := extractContentComments(content)
if text != content {
t.Fatalf("正文被改动:\n got %q\nwant %q", text, content)
}
if len(comments) != 0 {
t.Fatalf("不应解析出段评:%+v", comments)
}
}
func TestExtractContentCommentsEmpty(t *testing.T) {
if text, comments := extractContentComments(""); text != "" || comments != nil {
t.Fatalf("空正文应原样返回,得到 %q / %+v", text, comments)
}
if text, comments := extractContentComments("<p>只有普通正文</p>"); text != "<p>只有普通正文</p>" || comments != nil {
t.Fatalf("无标记正文应原样返回,得到 %q / %+v", text, comments)
}
}
func TestSplitImgSrcParams(t *testing.T) {
base, params := splitImgSrcParams(`data:image/svg+xml;base64,QUJD,{"click":"showCmt('u','x')","style":"text"}`)
if base != "data:image/svg+xml;base64,QUJD" {
t.Fatalf("base = %q", base)
}
if params["click"] != "showCmt('u','x')" || params["style"] != "text" {
t.Fatalf("params = %+v", params)
}
if base, params := splitImgSrcParams("https://x.example.com/a.jpg"); params != nil || base != "https://x.example.com/a.jpg" {
t.Fatalf("无配置时不应拆出参数:%q / %+v", base, params)
}
}
func TestValidateCommentURL(t *testing.T) {
bad := []string{
"", "not-a-url", "file:///etc/passwd", "javascript:alert(1)",
"http://localhost/c", "http://127.0.0.1/c", "http://192.168.1.1/c", "http://[::1]/c",
}
for _, u := range bad {
if _, err := validateCommentURL(u); err == nil {
t.Fatalf("%q 应被拒绝", u)
}
}
good := "https://v1.qingtian618.com/get_review?book_id=1&item_id=2"
if got, err := validateCommentURL(good); err != nil || got != good {
t.Fatalf("合法地址被拒:%q / %v", got, err)
}
}
// 端到端:书源正文里带段评标记时,GetContentForBook 应清掉标记、
// 下发结构化 Comments,且正文文字完好。
func TestGetContentForBookExtractsComments(t *testing.T) {
src := svgCommentSrc(t, 5, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
// 服务端按 hex 还原规则返回值里的正文;这里让正文规则直接返回一段放进 data 信封的 JSON。
pageJSON := fmt.Sprintf(`{"content":%q}`, "<p>第一段<img src=\""+src+"\"></p>\n<p>第二段</p>")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(pageJSON))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
srcJSON := fmt.Sprintf(`{
"bookSourceUrl": %q,
"bookSourceName": "段评测试源",
"bookSourceType": 0,
"ruleContent": { "content": "$.content" }
}`, srv.URL)
importTestSource(t, svc, srcJSON, srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "段评测试源",
BookURL: srv.URL + "/book/1",
Name: "测试书",
Type: 0,
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
}); err != nil {
t.Fatalf("写入章节失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("类型 = %q,期望 text", out.Type)
}
if strings.Contains(out.Content, "<img") || strings.Contains(out.Content, "showCmt") {
t.Fatalf("段评标记没有清干净:%q", out.Content)
}
if out.Content != "<p>第一段</p>\n<p>第二段</p>" {
t.Fatalf("正文被破坏:%q", out.Content)
}
if len(out.Comments) != 1 {
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
}
c := out.Comments[0]
if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
t.Fatalf("段评内容不符:%+v", c)
}
}
+5 -5
View File
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员"); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员")
books, skipped, err := svc.Search(ctx, "会员", nil)
if err != nil {
t.Fatal(err)
}
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员"); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意"); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意"); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
+137 -32
View File
@@ -45,8 +45,10 @@ type ReaderService struct {
// browserMu / browserPending 保护「待用户完成的页面」表
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
// browserSeq 是页面登记的单调递增序号,供前端判断哪个页面最新。
browserMu sync.Mutex
browserPending map[string]*pendingBrowser
browserSeq int64
// limiter 单源限速(书源 concurrentRate)。
limiter *sourceRateLimiter
@@ -737,17 +739,17 @@ type searchHit struct {
}
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, []SearchSkipped, error) {
//
// sourceIDs 是本次的搜索范围(对应 legado SearchScope.getBookSourceParts):
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, nil, err
}
var enabled []model.ReaderBookSource
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
enabled := enabledSourcesForScope(sources, sourceIDs)
if len(enabled) == 0 {
return nil, nil, fmt.Errorf("没有已启用的书源")
}
@@ -783,6 +785,34 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [
return mergeSearchResults(hits, key), skipped, nil
}
// enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。
// 空范围 = 全部启用;范围里的书源不存在或已停用时忽略;全被忽略则退回全部启用。
func enabledSourcesForScope(sources []model.ReaderBookSource, sourceIDs []string) []model.ReaderBookSource {
enabled := make([]model.ReaderBookSource, 0, len(sources))
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
if len(sourceIDs) == 0 {
return enabled
}
want := make(map[string]struct{}, len(sourceIDs))
for _, id := range sourceIDs {
want[id] = struct{}{}
}
scoped := make([]model.ReaderBookSource, 0, len(enabled))
for _, src := range enabled {
if _, ok := want[src.ID]; ok {
scoped = append(scoped, src)
}
}
if len(scoped) == 0 {
return enabled
}
return scoped
}
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
type SearchSkipped struct {
SourceID string `json:"source_id"`
@@ -1111,6 +1141,21 @@ func normalizeBookType(t int) int {
return 0
}
// contentTypeCode 把 ChapterContent.Type 换算回书架记录的类型码
// (0 文本 / 1 音频 / 2 图片,与 model.ReaderBook.Type 同一套约定)。
// 第二个返回值为 false 表示这个渲染类型没有对应的书籍类型(如视频)。
func contentTypeCode(t string) (int, bool) {
switch t {
case "text":
return 0, true
case "audio":
return 1, true
case "image":
return 2, true
}
return 0, false
}
// GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型
// (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。
func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) {
@@ -1358,6 +1403,9 @@ type ChapterContent struct {
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
// Comments 正文里的段评锚点(<comment> 标记与带 click 配置的内嵌评论图,见 comment.go)。
// 只在文本类型下有意义;前端按 Line 把气泡挂到对应段落旁。
Comments []ContentComment `json:"comments,omitempty"`
// Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了),
// 首次播放需要等转码完成,之后命中缓存秒开。
Transcoding bool `json:"transcoding,omitempty"`
@@ -1627,7 +1675,14 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
if declaredType >= 0 {
effective = declaredType
}
if effective < 0 {
// 书架类型还停在默认的「文本」时,用书源声明的类型兜底。
//
// 书籍详情页的「加入书架/开始阅读」曾经把 origin_type 写死成 0(见 web
// ReaderBookPage),听书/漫画书于是以文本类型落库;而书架类型优先级高于
// 书源类型,正文就被当文本渲染——听书源的播放直链会排满整个阅读页。
// 规则 JS 里显式声明的类型(declaredType)仍然最优先,所以「文本型聚合源
// 提供听书/漫画内容」这类靠 JS 声明类型的情形不受影响。
if declaredType < 0 && effective <= 0 {
effective = src.Type
}
switch effective {
@@ -1652,6 +1707,9 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
out.ImageStyle = SPtr(cr.ImageStyle)
default:
out.Type = "text"
// 正文里的段评标记(<comment …/> 或「图片地址 + click 配置」的内嵌评论图)
// 归一成结构化锚点,正文文字原样保留(见 comment.go 的说明)。
out.Content, out.Comments = extractContentComments(content)
}
return out, nil
}
@@ -2024,11 +2082,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b
// TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。
type TocRefreshResult struct {
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Failed int `json:"failed"` // 抓取或写入失败的书
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新)
Failed int `json:"failed"` // 抓取或写入失败的书
}
// tocRefreshStatus 单本书目录刷新的结果。
type tocRefreshStatus int
const (
// tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。
tocRefreshUnchanged tocRefreshStatus = iota
// tocRefreshUpdated 抓取成功且章数变多,有新章节。
tocRefreshUpdated
// tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。
tocRefreshFailed
)
// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。
const refreshTocConcurrency = 4
@@ -2037,6 +2108,8 @@ const refreshTocConcurrency = 4
// 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新
// latest_chapter_time。本地书籍与没有书源信息的书籍跳过。
// 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。
// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节),
// 混在一起会让人误以为书源坏了。
func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) {
books, err := s.repo.ListBooks(ctx, userID)
if err != nil {
@@ -2068,43 +2141,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To
defer func() { <-sem }()
bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout)
defer cancel()
if s.refreshBookToc(bookCtx, userID, b) {
mu.Lock()
res.Updated++
mu.Unlock()
return
}
status := s.refreshBookToc(bookCtx, userID, b)
mu.Lock()
res.Failed++
mu.Unlock()
defer mu.Unlock()
switch status {
case tocRefreshUpdated:
res.Updated++
case tocRefreshUnchanged:
res.Unchanged++
default:
res.Failed++
}
}(b)
}
wg.Wait()
return res, nil
}
// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool {
// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus {
before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID})
chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL)
if err != nil || len(chapters) == 0 {
if err != nil && s.log != nil {
s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err))
}
return false
if err != nil {
s.logTocRefreshFailure("reader: 更新目录失败", book, err)
return tocRefreshFailed
}
// 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。
// 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。
if len(chapters) == 0 {
s.logTocRefreshFailure("reader: 更新目录为空", book, nil)
return tocRefreshFailed
}
inputs := make([]ChapterInput, 0, len(chapters))
for _, ch := range chapters {
inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume})
}
if err := s.SaveChapters(ctx, book.ID, inputs); err != nil {
if s.log != nil {
s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err))
}
return false
s.logTocRefreshFailure("reader: 更新目录写入失败", book, err)
return tocRefreshFailed
}
beforeCount := before[book.ID]
return beforeCount > 0 && len(chapters) > beforeCount
// 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。
if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount {
return tocRefreshUpdated
}
return tocRefreshUnchanged
}
// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自
// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。
func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) {
if s.log == nil {
return
}
fields := []zap.Field{
zap.String("book", book.ID),
zap.String("name", book.Name),
zap.String("origin", book.OriginName),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.log.Warn(msg, fields...)
}
// ListReplaceRules 用户替换规则列表。
@@ -2229,6 +2326,14 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err))
}
} else if code, ok := contentTypeCode(out.Type); ok && code != 0 && code != book.Type {
// 书源类型兜底纠正出来的音频/漫画类型也落库:这些书被详情页写死的
// origin_type=0 记成了文本,不写回的话每读一章都要再纠正一次,
// 注入规则 JS 的 book.type 也一直是错的。只向上纠正,不覆盖成文本。
book.Type = code
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 修正书籍类型失败", zap.String("book", book.ID), zap.Error(err))
}
}
if out.Type == "text" {
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
+134
View File
@@ -0,0 +1,134 @@
package reader
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件:「更新目录」的结果分档回归测试。
//
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
// refreshTocForTest 跑一次「更新目录」并返回汇总。
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
t.Helper()
res, err := svc.RefreshBooksToc(ctx, "u1")
if err != nil {
t.Fatalf("更新目录失败: %v", err)
}
return res
}
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
(*tocHandler)(w, r)
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
}
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(e2eTocHTML))
})
srv := newRefreshTocServer(&tocHandler)
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "测试源",
BookURL: srv.URL + "/book/1",
TocURL: srv.URL + "/book/1/toc.html",
Name: "斗破苍穹",
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
res := refreshTocForTest(t, svc, ctx)
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
}
chapters, err := svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 2 {
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
}
// 2) 站点多出一章:这才算「有新章节」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(strings.Replace(
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
)))
}
res = refreshTocForTest(t, svc, ctx)
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
}
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
// 这种静默失败必须记失败,不能报成「已是最新」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
}
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
}
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
chapters, err = svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 3 {
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
}
// 4) 站点 404:抓取失败。
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 {
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
}
}
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
for _, b := range []*model.ReaderBook{local, sourceless} {
if err := svc.repo.CreateBook(ctx, b); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
}
res := refreshTocForTest(t, svc, ctx)
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
}
}
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅")
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅")
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out))
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
t.Fatalf("搜索失败: %v", err)
}
if gotQuery != "宠魅" {
@@ -0,0 +1,106 @@
package reader
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
t.Helper()
src := map[string]any{
"bookSourceUrl": serverURL,
"bookSourceName": name,
"searchUrl": serverURL + "/search?q={{key}}",
"ruleSearch": map[string]any{
"bookList": "$.data",
"name": "$.book_name",
"author": "$.author",
"bookUrl": "$.book_url",
},
}
out, _ := json.Marshal(src)
return string(out)
}
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
payload := map[string]any{
"data": []map[string]string{{
"book_name": bookName,
"author": "作者",
"book_url": "/book/1",
}},
}
_ = json.NewEncoder(w).Encode(payload)
}))
}
func TestSearchRespectsSourceScope(t *testing.T) {
srvA := scopeEchoServer(t, "甲书")
defer srvA.Close()
srvB := scopeEchoServer(t, "乙书")
defer srvB.Close()
svc, _ := newLoginTestService(t)
for _, raw := range []string{
scopeEchoSourceJSON(t, "源A", srvA.URL),
scopeEchoSourceJSON(t, "源B", srvB.URL),
} {
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
t.Fatal(err)
}
}
sources, err := svc.ListSources(t.Context())
if err != nil {
t.Fatal(err)
}
idByName := map[string]string{}
for _, s := range sources {
idByName[s.Name] = s.ID
}
names := func(books []SearchBook) map[string]bool {
out := map[string]bool{}
for _, b := range books {
out[b.Name] = true
}
return out
}
// 指定单个源:只应搜到该源的结果
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
if err != nil {
t.Fatal(err)
}
if len(skipped) > 0 {
t.Fatalf("源A 不应失败: %+v", skipped)
}
if got := names(books); len(got) != 1 || !got["甲书"] {
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
}
// 空范围:默认全部启用书源,两个源都应命中
books, _, err = svc.Search(t.Context(), "书", nil)
if err != nil {
t.Fatal(err)
}
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
}
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
if err != nil {
t.Fatal(err)
}
if got := names(books); len(got) != 2 {
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
}
}
+3
View File
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
@@ -93,6 +94,8 @@ export default function App() {
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
<ReaderMiniPlayer />
</Suspense>
</AppErrorBoundary>
)
+32 -4
View File
@@ -118,10 +118,26 @@ export interface ReaderChapterContent {
tracks?: string[]
images?: string[]
image_style?: string
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
comments?: ReaderContentComment[]
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
transcoding?: boolean
}
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
export interface ReaderContentComment {
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
line: number
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
count: number
/** 评论地址(书源 showCmt 的第一个参数)。 */
url: string
/** 段评 / 本章说。 */
label?: string
/** 独占一行的整块评论(章末「本章说」)。 */
block?: boolean
}
export interface ReaderReplaceRule {
id: string
name: string
@@ -174,6 +190,8 @@ export interface ReaderBrowserPage {
title: string
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
mode: 'wait' | 'open'
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
seq: number
/** iframe 承载地址(同源,带签名)。 */
page_url: string
refetch: boolean
@@ -223,6 +241,9 @@ export const readerAPI = {
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
api.post('/reader/browser/result', body).then((r) => r.data),
// 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
openContentComment: (body: { book_id: string; url: string; title?: string }) =>
api.post<ReaderBrowserPage>('/reader/comments/open', body).then((r) => r.data),
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
browserXHR: (body: {
id: string
@@ -245,11 +266,13 @@ export const readerAPI = {
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
// ── 搜索 / 详情 / 目录 / 正文 ──
search: (key: string) =>
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
search: (key: string, sourceIds: string[] = []) =>
api
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
'/reader/search',
{ key },
{ key, source_ids: sourceIds },
longOpts,
)
.then((r) => r.data),
@@ -315,11 +338,16 @@ export const readerAPI = {
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
/**
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
*/
refreshBooksToc: () =>
api
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
'/reader/shelf/refresh-toc',
{},
longOpts,
)
.then((r) => r.data),
/**
* 换源:把书架里的书切到另一个书源。
+1 -1
View File
@@ -4,7 +4,7 @@ import type { BookGroupTab } from '../utils/readerBookGroups'
/**
* 书架分组栏(对齐影视模块的 LibraryTagBar):
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
* 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。
*
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
+42 -317
View File
@@ -1,28 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ChevronLeft,
ChevronRight,
Gauge,
ListMusic,
Loader2,
Pause,
Play,
Scissors,
Timer,
X,
} from 'lucide-react'
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { ReaderChapter } from '../../api/reader'
import {
AUDIO_CREDITS_MAX,
AUDIO_SPEEDS,
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { readerCoverSrc } from '../../utils/readerCover'
import { getReaderAudioEngine } from './readerAudioEngine'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
// hls.js 播 m3u8,<audio> 播直链。
@@ -30,35 +14,12 @@ import { readerCoverSrc } from '../../utils/readerCover'
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
//
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
interface ReaderAudioPanelProps {
src: string
title: string
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
cover: string
theme: { bg: string; text: string; accent: string }
initialPos: number // 恢复进度(秒)
/** 片头跳过秒数(Book.openCredits)。 */
openCredits: number
/** 片尾跳过秒数(Book.closeCredits)。 */
closeCredits: number
chapters: ReaderChapter[]
chapterIndex: number | null
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
hasNext: boolean
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
transcoding?: boolean
onProgress: (seconds: number) => void
/**
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
*/
onCommitProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
onEnded: () => void
onCreditsChange: (open: number, close: number) => void
onToggleMenu: () => void
}
@@ -84,56 +45,34 @@ function fmtCountdown(sec: number): string {
return `${m}:${String(s).padStart(2, '0')}`
}
export function ReaderAudioPanel({
src,
title,
cover,
theme,
initialPos,
openCredits,
closeCredits,
chapters,
chapterIndex,
hasNext,
transcoding = false,
onProgress,
onCommitProgress,
onPrevChapter,
onNextChapter,
onSelectChapter,
onEnded,
onCreditsChange,
onToggleMenu,
}: ReaderAudioPanelProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const hlsRef = useRef<{ destroy: () => void } | null>(null)
const restoredRef = useRef(false)
const skippedEndRef = useRef(false)
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
const engine = useMemo(() => getReaderAudioEngine(), [])
const title = useReaderAudioStore((s) => s.chapterTitle)
const cover = useReaderAudioStore((s) => s.cover)
const chapters = useReaderAudioStore((s) => s.chapters)
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
const status = useReaderAudioStore((s) => s.status)
const audioError = useReaderAudioStore((s) => s.error)
const cur = useReaderAudioStore((s) => s.cur)
const dur = useReaderAudioStore((s) => s.dur)
const speed = useReaderAudioStore((s) => s.speed)
const transcoding = useReaderAudioStore((s) => s.transcoding)
const openCredits = useReaderAudioStore((s) => s.openCredits)
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
const timerActive = useReaderAudioStore((s) => s.timerActive)
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
const playing = status === 'playing'
const loading = status === 'loading'
const tocRef = useRef<VirtuosoHandle>(null)
const speed = useReaderSettingsStore((s) => s.audioSpeed)
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
const [playing, setPlaying] = useState(false)
const [cur, setCur] = useState(0)
const [dur, setDur] = useState(0)
const [loading, setLoading] = useState(true)
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
const [groupIndex, setGroupIndex] = useState(0)
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
const lastTimeRef = useRef(0)
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
const commitRef = useRef(onCommitProgress)
useEffect(() => {
commitRef.current = onCommitProgress
}, [onCommitProgress])
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
@@ -142,168 +81,22 @@ export function ReaderAudioPanel({
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
const coverSrc = readerCoverSrc(cover)
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
const [timerLeft, setTimerLeft] = useState(0)
const [timerActive, setTimerActive] = useState(false)
const [timerPreset, setTimerPreset] = useState(defaultTimer)
const timerDeadlineRef = useRef(0)
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
useEffect(() => {
setCreditDraft({ open: openCredits, close: closeCredits })
}, [openCredits, closeCredits])
// ── 换源/换章:重建播放器 ──
useEffect(() => {
const audio = audioRef.current
if (!audio) return
restoredRef.current = false
skippedEndRef.current = false
setLoading(true)
setAudioError('')
setCur(0)
setDur(0)
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
lastTimeRef.current = 0
let cancelled = false
let hls: { destroy: () => void } | null = null
const setup = async () => {
if (src.includes('.m3u8')) {
const mod = await import('hls.js')
const Hls = mod.default
if (cancelled) return
if (Hls.isSupported()) {
const inst = new Hls({ enableWorker: true })
inst.loadSource(src)
inst.attachMedia(audio)
inst.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoading(false)
})
hls = inst
hlsRef.current = inst
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = src // Safari 原生 HLS
}
} else {
audio.src = src
}
}
void setup()
return () => {
cancelled = true
hls?.destroy()
hlsRef.current = null
audio.removeAttribute('src')
audio.load()
}
}, [src])
useEffect(() => {
const audio = audioRef.current
if (audio) audio.playbackRate = speed
}, [speed])
// ── 离开这一章时补报进度 ──
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
// 因此不会把上一章的位置写到新章节头上。
useEffect(() => {
const flush = () => {
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pagehide', flush)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pagehide', flush)
flush()
}
}, [])
// ── 定时关闭 ──
const applyTimer = useCallback(
(minutes: number) => {
setDefaultTimer(minutes)
setTimerPreset(minutes)
if (minutes <= 0) {
timerDeadlineRef.current = 0
setTimerLeft(0)
setTimerActive(false)
return
}
timerDeadlineRef.current = Date.now() + minutes * 60_000
setTimerLeft(minutes * 60)
setTimerActive(true)
},
[setDefaultTimer],
)
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
const timerBootRef = useRef(false)
useEffect(() => {
if (timerBootRef.current) return
timerBootRef.current = true
if (defaultTimer > 0) applyTimer(defaultTimer)
}, [applyTimer, defaultTimer])
useEffect(() => {
if (!timerActive) return
const id = window.setInterval(() => {
const audio = audioRef.current
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
if (audio && audio.paused) {
timerDeadlineRef.current += 1000
return
}
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
setTimerLeft(left)
if (left <= 0) {
timerDeadlineRef.current = 0
setTimerActive(false)
audio?.pause()
toast('定时结束,已暂停播放')
}
}, 1000)
return () => window.clearInterval(id)
}, [timerActive])
// ── 播放控制 ──
const toggle = () => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) void audio.play().catch(() => undefined)
else audio.pause()
}
const seekBy = useCallback((delta: number) => {
const audio = audioRef.current
if (!audio) return
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
audio.currentTime = next
setCur(next)
}, [])
const seekTo = useCallback((seconds: number) => {
const audio = audioRef.current
if (!audio) return
audio.currentTime = seconds
setCur(seconds)
}, [])
const changeSpeed = (v: number) => {
setSpeed(v)
}
const toggle = () => engine.toggle()
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
const changeSpeed = (v: number) => engine.setSpeed(v)
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
const saveCredits = (open: number, close: number) => {
setCreditDraft({ open, close })
onCreditsChange(open, close)
engine.setCredits(open, close)
}
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
@@ -326,14 +119,11 @@ export function ReaderAudioPanel({
}
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
setSheet(next)
}
const chapterList = useMemo(
() => chapters.map((c, i) => ({ ...c, i })),
[chapters],
)
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
@@ -446,7 +236,7 @@ export function ReaderAudioPanel({
>
<button
type="button"
onClick={onPrevChapter}
onClick={() => engine.prev()}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="上一章"
title="上一章"
@@ -492,7 +282,7 @@ export function ReaderAudioPanel({
</button>
<button
type="button"
onClick={onNextChapter}
onClick={() => engine.next()}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="下一章"
title="下一章"
@@ -602,7 +392,7 @@ export function ReaderAudioPanel({
<Virtuoso
ref={tocRef}
data={chapterList}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
initialTopMostItemIndex={Math.max(0, chapterIndex)}
rangeChanged={(range) =>
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
}
@@ -614,7 +404,7 @@ export function ReaderAudioPanel({
onClick={() => {
if (row.is_volume) return
closeSheet()
onSelectChapter(row.i)
engine.selectChapter(row.i)
}}
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
row.is_volume ? 'font-bold opacity-70' : ''
@@ -764,71 +554,6 @@ export function ReaderAudioPanel({
</div>
</>
)}
<audio
ref={audioRef}
className="hidden"
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={(e) => {
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
const code = e.currentTarget.error?.code
setLoading(false)
setAudioError(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
: code === MediaError.MEDIA_ERR_NETWORK
? '音频加载失败,请检查网络或源文件是否还在'
: '音频无法播放',
)
}}
onLoadedMetadata={(e) => {
const audio = e.currentTarget
setDur(audio.duration)
setLoading(false)
audio.playbackRate = speed
if (!restoredRef.current) {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
if (initialPos > 0 && (total === 0 || initialPos < total)) {
// 有进度:按存档续播(legado: position != 0 时不套用片头)
audio.currentTime = initialPos
setCur(initialPos)
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
// 全新开播:跳到片头结束位置(legado: skipStartMs)
audio.currentTime = openCredits
setCur(openCredits)
}
}
restoredRef.current = true
void audio.play().catch(() => undefined)
}}
onTimeUpdate={(e) => {
const audio = e.currentTarget
const t = audio.currentTime
lastTimeRef.current = t
setCur(t)
onProgress(t)
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
if (
closeCredits > 0 &&
!skippedEndRef.current &&
Number.isFinite(audio.duration) &&
audio.duration > closeCredits + 1 &&
t >= audio.duration - closeCredits
) {
skippedEndRef.current = true
if (!hasNext) {
audio.pause()
audio.currentTime = Math.max(0, audio.duration - 0.5)
return
}
onEnded()
}
}}
onEnded={onEnded}
/>
</div>
)
}
+13 -1
View File
@@ -24,6 +24,8 @@ export default function ReaderBookPage() {
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
const [busy, setBusy] = useState(false)
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
const [sourceType, setSourceType] = useState(0)
const [tocExpanded, setTocExpanded] = useState(false)
const [introExpanded, setIntroExpanded] = useState(false)
// 换源:候选源来自按书名重新搜索的结果
@@ -58,6 +60,16 @@ export default function ReaderBookPage() {
.catch(() => setChapters([]))
}, [bookURL, sourceURL])
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
useEffect(() => {
if (!sourceURL) return
readerAPI
.listSources()
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
.catch(() => undefined)
}, [sourceURL])
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
useEffect(() => {
const tocURL = info?.toc_url
@@ -84,7 +96,7 @@ export default function ReaderBookPage() {
source_id: originID,
origin: sourceURL,
origin_name: originName || info?.name || '',
origin_type: 0,
origin_type: sourceType,
book_url: bookURL,
latest_chapter: info?.latest_chapter ?? '',
},
+42 -5
View File
@@ -3,12 +3,15 @@ import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { rememberImageSize } from '../../utils/comicSpread'
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
import type { ComicImageFit } from '../../stores/readerSettings'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
//
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
// 这里只负责把它们并排摆好、各自撑满视口高度。
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
interface ReaderComicProps {
images: string[]
@@ -22,6 +25,8 @@ interface ReaderComicProps {
onScrolled: () => void
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
spread?: number[] | null
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
imageFit?: ComicImageFit
}
function ComicImage({
@@ -29,13 +34,19 @@ function ComicImage({
theme,
fit = false,
half = false,
imageFit = 'default',
viewportHeight = 0,
}: {
src: string
theme: { bg: string; text: string; accent: string }
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
fit?: boolean
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
half?: boolean
// imageFit:滚动模式的显示尺寸档位。
imageFit?: ComicImageFit
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
viewportHeight?: number
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -45,6 +56,15 @@ function ComicImage({
setState('loading')
}, [src])
const loaded = state === 'ok'
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
const sizing = fit
? {
className: 'block max-h-full w-auto max-w-full object-contain',
style: loaded ? {} : { minHeight: '10rem' },
}
: comicImageSizing(imageFit, viewportHeight, loaded)
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
@@ -71,8 +91,8 @@ function ComicImage({
setState('ok')
}}
onError={() => setState('error')}
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
className={sizing.className}
style={sizing.style}
/>
{state === 'loading' && (
<div
@@ -94,10 +114,27 @@ function ComicImage({
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
// 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
// root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
// 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
const [viewportHeight, setViewportHeight] = useState(0)
const selfSized = isSelfSizedComicFit(imageFit)
useEffect(() => {
// 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
if (mode !== 'scroll' || !selfSized) return
const el = scrollRef.current
if (!el) return
const update = () => setViewportHeight(el.clientHeight)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [mode, selfSized])
// 滚动模式:恢复进度(图片序号)并上报当前图
useEffect(() => {
@@ -183,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
}
}}
>
<ComicImage src={src} theme={theme} />
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
{i + 1} / {images.length}
</p>
+5 -1
View File
@@ -250,7 +250,11 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
if (res.total === 0) {
toast.success('没有需要更新的网络书籍', { id: toastId })
} else if (res.failed > 0) {
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
// 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档
const parts = [`${res.updated} 本有新章节`]
if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`)
parts.push(`${res.failed} 本失败,可在日志中查看书源报错`)
toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' })
} else {
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
}
+74
View File
@@ -0,0 +1,74 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { Loader2, Pause, Play, X } from 'lucide-react'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { readerCoverSrc } from '../../utils/readerCover'
import { getReaderAudioEngine } from './readerAudioEngine'
// 全局听书迷你条。
//
// 播放引擎是常驻单例(见 readerAudioEngine.ts),离开阅读页后音频仍在播;这个条
// 保证在其他页面也能暂停/回到阅读,否则用户会「听到声音但找不到控制」。
// 面板所在的阅读页内部不显示它(那里有完整播放面板),避免两套控制打架。
export function ReaderMiniPlayer() {
const location = useLocation()
const navigate = useNavigate()
const track = useReaderAudioStore((s) => s.track)
const bookId = useReaderAudioStore((s) => s.bookId)
const title = useReaderAudioStore((s) => s.chapterTitle)
const bookName = useReaderAudioStore((s) => s.bookName)
const cover = useReaderAudioStore((s) => s.cover)
const status = useReaderAudioStore((s) => s.status)
const onReaderView = bookId !== '' && location.pathname === `/reader/view/${bookId}`
if (!track || !bookId || onReaderView || status === 'idle') return null
const engine = getReaderAudioEngine()
const playing = status === 'playing'
const loading = status === 'loading'
const coverSrc = readerCoverSrc(cover)
return (
<div
className="fixed inset-x-3 z-[60] flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]/95 p-2 shadow-2xl backdrop-blur-md lg:inset-x-auto lg:right-6 lg:w-96"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 3.75rem)' }}
>
<button
type="button"
onClick={() => navigate(`/reader/view/${bookId}`)}
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
title="回到阅读页"
>
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-brand-soft)]">
{coverSrc ? (
<img src={coverSrc} alt="" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-bold text-[var(--app-text)]">{title || bookName}</span>
<span className="block truncate text-[10px] text-[var(--app-muted)]">{bookName || 'MeBox 听书'}</span>
</span>
</button>
<button
type="button"
onClick={() => engine.toggle()}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm"
aria-label={playing ? '暂停' : '播放'}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : playing ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
type="button"
onClick={() => engine.stop()}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-muted)] hover:text-[var(--app-text)]"
aria-label="停止播放"
title="停止播放"
>
<X size={16} />
</button>
</div>
)
}
+118 -7
View File
@@ -1,14 +1,32 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { splitKindTags } from '../../utils/kindTags'
import { SearchScopeDialog } from './SearchScopeDialog'
import { SourcePickerDialog } from './SourcePickerDialog'
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
function shelfBookKey(name: string, author: string): string {
const n = name.trim()
const a = author.trim()
return a ? `${n}|${a}` : n
}
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
if (shelf.has(shelfBookKey(book.name, book.author))) return true
if (book.book_url && shelf.has(book.book_url)) return true
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
}
export default function ReaderSearchPage() {
const navigate = useNavigate()
@@ -23,13 +41,60 @@ export default function ReaderSearchPage() {
const [adding, setAdding] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
const [sources, setSources] = useState<ReaderSource[]>([])
const [showScope, setShowScope] = useState(false)
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
const activeScopeIds = useMemo(
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
[scopeIds, enabledSources],
)
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
// 避免已保存子集在加载瞬间被错标成「全部书源」。
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
useEffect(() => {
readerAPI.listSources().then(setSources).catch(() => undefined)
}, [])
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
useEffect(() => {
readerAPI
.listBooks()
.then((books) => {
const keys = new Set<string>()
for (const b of books) {
keys.add(shelfBookKey(b.name, b.author))
if (b.book_url) keys.add(b.book_url)
}
setShelfKeys(keys)
})
.catch(() => undefined)
}, [])
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
// 且刚加进书架的书也会立刻从列表消失。
const visibleBooks = useMemo(
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
[books, shelfKeys],
)
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
const doSearch = async () => {
const kw = key.trim()
if (!kw || searching) return
setSearching(true)
setShowSkipped(false)
try {
const res = await readerAPI.search(kw)
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
const res = await readerAPI.search(kw, scopeIds)
setBooks(res.books ?? [])
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
@@ -52,12 +117,20 @@ export default function ReaderSearchPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
const addToShelf = async (book: ReaderSearchBook) => {
const origin = book.origins[0]
if (!origin) return
setAdding(book.book_url)
try {
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
toast.success(`《${book.name}》已加入书架`)
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
setShelfKeys((prev) => {
const next = new Set(prev)
next.add(shelfBookKey(book.name, book.author))
if (origin.book_url) next.add(origin.book_url)
return next
})
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
toast.error(msg)
@@ -113,6 +186,22 @@ export default function ReaderSearchPage() {
</button>
</div>
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => setShowScope(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
>
<ListFilter size={13} />
搜索范围
<span className="text-brand-600">{scopeLabel}</span>
</button>
{scopeAll && enabledSources.length > 0 && (
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
)}
</div>
{searching && (
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
@@ -120,9 +209,19 @@ export default function ReaderSearchPage() {
</div>
)}
{!searching && books !== null && (
{!searching && visibleBooks !== null && (
<div className="mt-6 space-y-3">
{books.map((book) => (
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
已隐藏 {hiddenOnShelf} 本已在书架里的书
</p>
)}
{visibleBooks.length === 0 && (
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
</p>
)}
{visibleBooks.map((book) => (
<div
key={`${book.name}|${book.author}`}
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
@@ -225,6 +324,18 @@ export default function ReaderSearchPage() {
onClose={() => setPicker(null)}
/>
)}
{showScope && (
<SearchScopeDialog
sources={sources}
selectedIds={scopeIds}
onConfirm={(ids) => {
setSearchScopeIds(ids)
setShowScope(false)
}}
onClose={() => setShowScope(false)}
/>
)}
</div>
)
}
+214 -58
View File
@@ -9,6 +9,7 @@ import {
LayoutList,
ListEnd,
Loader2,
MessageSquare,
Minus,
Moon,
Plus,
@@ -17,14 +18,25 @@ import {
} from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import {
readerAPI,
type ReaderBook,
type ReaderBrowserPage,
type ReaderChapter,
type ReaderChapterContent,
type ReaderContentComment,
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
import { getReaderAudioEngine } from './readerAudioEngine'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
@@ -61,6 +73,12 @@ const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
interface ReaderParagraphBlock {
text: string
comments: ReaderContentComment[]
}
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -91,6 +109,8 @@ export default function ReaderViewPage() {
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
const [reloadKey, setReloadKey] = useState(0)
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
const topBarRef = useRef<HTMLDivElement>(null)
const [menuInset, setMenuInset] = useState(0)
@@ -149,6 +169,19 @@ export default function ReaderViewPage() {
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
// ── 漫画滚动模式的图片显示尺寸 ──
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
// 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
/** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
/**
* 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
* 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
* 900px 的列会把它们再压回去。
*/
const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
@@ -180,11 +213,28 @@ export default function ReaderViewPage() {
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
let idx = firstReadableIndex(chs)
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
const explicitChapter = Number.isInteger(qChapter) && !!chs[qChapter] && !chs[qChapter].is_volume
if (explicitChapter) idx = qChapter
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
idx = b.dur_chapter_index
}
pendingPosRef.current = b.dur_chapter_pos ?? 0
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
const live = useReaderAudioStore.getState()
if (
!explicitChapter &&
live.bookId === b.id &&
live.track !== '' &&
(live.status === 'playing' || live.status === 'loading' || live.status === 'paused') &&
live.chapterIndex !== idx &&
chs[live.chapterIndex]
) {
idx = live.chapterIndex
// 位置由引擎自己掌握,服务端那份属于上一章,别写过去
pendingPosRef.current = 0
}
setChapterIndex(idx)
} catch (e) {
if (!cancelled) setError((e as Error).message || '加载失败')
@@ -227,6 +277,22 @@ export default function ReaderViewPage() {
setRestorePos(savedPos)
pendingPosRef.current = 0
}
// 听书:把这一章交给常驻播放引擎。引擎是全局单例,离开本页也不会被卸载,
// 因此换章/离开路由/息屏都继续播(面板卸载不再掐播放)。
if (ct.type === 'audio' && ct.tracks && ct.tracks.length > 0) {
getReaderAudioEngine().loadChapter({
bookId: book.id,
bookName: book.name,
cover: book.cover_url ?? '',
chapters,
chapterIndex,
track: ct.tracks[0],
transcoding: ct.transcoding ?? false,
openCredits: book.open_credits ?? 0,
closeCredits: book.close_credits ?? 0,
initialPos: savedPos,
})
}
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
// 刚恢复的进度又写成 0,退出重进就从头开始。
@@ -418,14 +484,17 @@ export default function ReaderViewPage() {
[chapterIndex, chapters.length, panel],
)
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
const hasNextAudioChapter = useMemo(() => {
if (chapterIndex === null) return false
for (let i = chapterIndex + 1; i < chapters.length; i++) {
if (!chapters[i].is_volume) return true
}
return false
}, [chapterIndex, chapters])
// 听书:引擎自己也可能切章(播完自动下一章、锁屏/耳机按键上一章下一章),把它的
// 当前章节回写进本页,目录与菜单才不会停在旧章节上。只认本书,避免串到别的书。
const audioChapterIndex = useReaderAudioStore((s) =>
book && s.bookId === book.id ? s.chapterIndex : null,
)
useEffect(() => {
if (contentType !== 'audio' || audioChapterIndex === null) return
if (audioChapterIndex === chapterIndex) return
pendingEndRef.current = false
setChapterIndex(audioChapterIndex)
}, [audioChapterIndex, chapterIndex, contentType])
// ── 换源(对应 legado 阅读页的「换源」) ──
//
@@ -475,19 +544,11 @@ export default function ReaderViewPage() {
// 本地导入的书没有书源,不显示换源入口
const canSwitchSource = !!book && !book.is_local
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
const saveAudioCredits = useCallback(
(open: number, close: number) => {
if (!book) return
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
},
[book],
)
// 听书:片头/片尾跳过秒数的落库改由播放引擎负责(它持有当前书 ID,离开本页也能写)。
const goPrev = useCallback(() => {
if (contentType === 'audio') {
goChapter(-1)
getReaderAudioEngine().prev()
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
@@ -511,7 +572,7 @@ export default function ReaderViewPage() {
const goNext = useCallback(() => {
if (contentType === 'audio') {
goChapter(1)
getReaderAudioEngine().next()
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
@@ -672,15 +733,59 @@ export default function ReaderViewPage() {
},
}
})()
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
// 正文按行组织成「段落块」:保留原始行号(段评锚点按行号挂靠),
// 只丢掉既无文字又无段评的空行。
const blocks = useMemo<ReaderParagraphBlock[]>(() => {
const lines = (content ?? '').split('\n')
const byLine = new Map<number, ReaderContentComment[]>()
for (const c of media?.type === 'text' ? (media.comments ?? []) : []) {
const list = byLine.get(c.line)
if (list) list.push(c)
else byLine.set(c.line, [c])
}
const out: ReaderParagraphBlock[] = []
for (let i = 0; i < lines.length; i++) {
const text = lines[i].trim()
const comments = byLine.get(i) ?? []
if (text === '' && comments.length === 0) continue
out.push({ text, comments })
}
return out
}, [content, media])
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
const renderParagraph = (line: string, key: number) => {
if (line.startsWith(IMG_MARK)) {
// 打开段评:服务端带书源 Cookie 抓取评论页,这里用 iframe 承载。
// 前端直接 window.open 会以未登录身份访问,评论接口拿不到数据。
const openComment = useCallback(
async (comment: ReaderContentComment) => {
if (!book) return
if (!comment.url) {
toast('这条评论没有可用地址')
return
}
try {
const page = await readerAPI.openContentComment({
book_id: book.id,
url: comment.url,
title: comment.label || '段评',
})
setBrowserPage(page)
} catch (e) {
toast.error(
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '打开段评失败',
)
}
},
[book],
)
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
const { text, comments } = block
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
<img
src={line.slice(IMG_MARK.length)}
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
@@ -688,9 +793,44 @@ export default function ReaderViewPage() {
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
{line}
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: settings.paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
// 点气泡会顺带翻页或呼出菜单。
e.stopPropagation()
void openComment(c)
}}
title={c.label || '段评'}
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: theme.accent,
color: theme.accent,
fontSize: Math.max(10, settings.fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
}
@@ -711,30 +851,10 @@ export default function ReaderViewPage() {
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className={`mx-auto h-full w-full ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<ReaderAudioPanel
src={media.tracks[0]}
title={currentChapter?.title ?? book?.name ?? '播放'}
cover={book?.cover_url ?? ''}
theme={theme}
initialPos={restorePos}
openCredits={book?.open_credits ?? 0}
closeCredits={book?.close_credits ?? 0}
chapters={chapters}
chapterIndex={chapterIndex}
hasNext={hasNextAudioChapter}
transcoding={media.transcoding ?? false}
onProgress={throttledMediaSave}
onCommitProgress={savePos}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onSelectChapter={jumpToChapter}
onEnded={() => goChapter(1)}
onCreditsChange={saveAudioCredits}
onToggleMenu={() => setMenuOpen((v) => !v)}
/>
<ReaderAudioPanel theme={theme} onToggleMenu={() => setMenuOpen((v) => !v)} />
) : (
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
@@ -746,6 +866,7 @@ export default function ReaderViewPage() {
theme={theme}
mode={settings.pageMode}
page={comicPage}
imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
@@ -761,8 +882,16 @@ export default function ReaderViewPage() {
) : settings.pageMode === 'page' ? (
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
不改高度也就不触发重新分页) */
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
不改高度也就不触发重新分页)。
relative + z-index + pointer-events-none:正文容器带 transform,
本身就形成一个层叠上下文,段评气泡的 z-index 只在容器内部生效,
会被后面的点击覆盖层压住。把整层抬到覆盖层之上并关闭指针事件,
只让气泡自己 pointer-events:auto,气泡可点、其余位置照样透给
覆盖层翻页/呼出菜单。 */
<div
className={`pointer-events-none relative z-[5] h-full px-4 py-2 ${MENU_SHIFT}`}
style={menuShiftStyle}
>
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
@@ -777,7 +906,7 @@ export default function ReaderViewPage() {
lineHeight: settings.lineHeight,
}}
>
{paragraphs.map((p, i) => renderParagraph(p, i))}
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div>
</div>
@@ -801,13 +930,13 @@ export default function ReaderViewPage() {
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
{paragraphs.map((p, i) => renderParagraph(p, i))}
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div>
)}
{/* 加载 / 错误 / 空内容态 */}
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
{(loadingStage !== null || (content !== null && blocks.length === 0)) && contentType === 'text' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
{loadingStage !== null ? (
<Loader2 className="animate-spin opacity-60" size={24} />
@@ -841,7 +970,7 @@ export default function ReaderViewPage() {
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
type="button"
@@ -975,7 +1104,7 @@ export default function ReaderViewPage() {
))}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
{panel === 'style' && (
<div
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
@@ -1029,6 +1158,30 @@ export default function ReaderViewPage() {
</button>
</div>
</div>
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
{showComicImageFit && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span className="opacity-70">图片尺寸</span>
{COMIC_IMAGE_FITS.map((item) => {
const active = settings.comicImageFit === item.id
return (
<button
key={item.id}
type="button"
onClick={() => settings.setComicImageFit(item.id)}
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
style={{
borderColor: active ? theme.accent : theme.text + '44',
color: active ? theme.accent : theme.text,
}}
>
{item.label}
</button>
)
})}
</div>
)}
</div>
)}
</div>
@@ -1124,6 +1277,9 @@ export default function ReaderViewPage() {
onClose={() => setSwitchOpen(false)}
/>
)}
{/* 段评评论页:复用书源页面的承载面板(带书源 Cookie 由服务端抓取) */}
{browserPage && <BrowserPanel page={browserPage} onClose={() => setBrowserPage(null)} />}
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
import { useMemo, useState } from 'react'
import { Check, Minus, Search, X } from 'lucide-react'
import type { ReaderSource } from '../../api/reader'
/**
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
*
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
*
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
*/
export function SearchScopeDialog({
sources,
selectedIds,
onConfirm,
onClose,
}: {
sources: ReaderSource[]
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
selectedIds: string[]
onConfirm: (ids: string[]) => void
onClose: () => void
}) {
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
const [selected, setSelected] = useState<Set<string>>(() => {
const valid = selectedIds.filter((id) => enabledIds.includes(id))
return new Set(valid.length > 0 ? valid : enabledIds)
})
const [filter, setFilter] = useState('')
const keyword = filter.trim().toLowerCase()
const visible = useMemo(
() =>
sources.filter(
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
),
[sources, keyword],
)
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
const groups = useMemo(() => {
const map = new Map<string, ReaderSource[]>()
for (const s of visible) {
const key = (s.group ?? '').trim() || '未分组'
const list = map.get(key)
if (list) list.push(s)
else map.set(key, [s])
}
return [...map.entries()].map(([name, items]) => ({ name, items }))
}, [visible])
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const toggleGroup = (ids: string[]) =>
setSelected((prev) => {
const next = new Set(prev)
const allIn = ids.every((id) => next.has(id))
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
return next
})
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
const clearVisible = () =>
setSelected((prev) => {
const next = new Set(prev)
visibleEnabledIds.forEach((id) => next.delete(id))
return next
})
const confirm = () => {
const picked = enabledIds.filter((id) => selected.has(id))
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
onConfirm(picked.length === enabledIds.length ? [] : picked)
}
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
<div className="min-w-0">
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="过滤书源名 / 分组"
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
/>
</div>
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
全选
</button>
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
清空
</button>
</div>
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
{sources.length === 0 ? (
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
) : groups.length === 0 ? (
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
) : (
<div className="space-y-2">
{groups.map((group) => {
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
return (
<div key={group.name}>
<button
type="button"
disabled={groupEnabledIds.length === 0}
onClick={() => toggleGroup(groupEnabledIds)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
>
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
{group.name}
</span>
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
</button>
<div className="mt-0.5 space-y-0.5">
{group.items.map((src) => (
<button
key={src.id}
type="button"
disabled={!src.enabled}
onClick={() => toggle(src.id)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
>
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
{!src.enabled && (
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
已禁用
</span>
)}
</button>
))}
</div>
</div>
)
})}
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
<span className="flex-1 text-2xs text-[var(--app-muted)]">
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
</span>
<button type="button" onClick={onClose} className="btn-outline text-xs">
取消
</button>
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
确定
</button>
</div>
</div>
</div>
)
}
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
return (
<span
aria-hidden
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
} ${disabled ? 'opacity-40' : ''}`}
>
{checked && <Check size={11} strokeWidth={3} />}
{!checked && partial && <Minus size={11} strokeWidth={3} />}
</span>
)
}
+10 -2
View File
@@ -70,8 +70,16 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
const pollBrowserPages = useCallback(async () => {
try {
const pages = await readerAPI.browserPending(sourceId)
const next = pages.find((p) => !handledPages.current.has(p.id))
if (next) setBrowserPage(next)
// 只取本次还没处理过的页面,并按 seq 取最新登记的那个。
// 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序
// 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上
// (光遇聚合「更新书源」残留后串页就是这么来的)。
const next = pages
.filter((p) => !handledPages.current.has(p.id))
.reduce<ReaderBrowserPage | null>((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null)
if (!next) return
// 已经在展示的页面不要被更旧的页面替换掉
setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next))
} catch {
// 轮询失败不打断正在执行的动作
}
+611
View File
@@ -0,0 +1,611 @@
// 听书播放引擎:模块级单例,持有真正的 <audio>(不进 React 树,也不进任何路由)。
//
// 为什么要脱离组件:音频面板原先挂在 ReaderViewPage 里,路由一变组件卸载,清理
// 逻辑就 removeAttribute('src') + load(),播放当场断掉;手机息屏、系统回收标签页
// 时更没有任何东西证明「这个页面正在播媒体」。把播放器做成常驻单例后:
// - 路由切换不影响播放;
// - Media Session 让锁屏/通知栏/耳机按键接管控制(Android Chrome 会据此判定
// 页面处于活跃媒体会话,后台存活率明显更高);
// - 定时关闭改由 timeupdate(后台播放时仍触发)+ 低频 ticker 驱动,不再依赖
// 被节流的 setInterval 来「到点暂停」。
//
// 进度上报也在这里:引擎自己知道当前书/章,离开页面、暂停、切章时都能补报一次,
// 不依赖调用方在卸载时回调。
import toast from 'react-hot-toast'
import { readerAPI, type ReaderChapter } from '../../api/reader'
import { useReaderAudioStore, resetReaderAudio } from '../../stores/readerAudio'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { readerCoverSrc } from '../../utils/readerCover'
/** 播放进度上报节流(毫秒):退出最多丢 5 秒。 */
const PROGRESS_SAVE_INTERVAL_MS = 5_000
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
const SEEK_STEP = 15
export interface LoadChapterInput {
bookId: string
bookName: string
cover: string
chapters: ReaderChapter[]
chapterIndex: number
/** 当前章的音轨地址(已签名)。 */
track: string
transcoding: boolean
openCredits: number
closeCredits: number
/** 恢复进度(秒);0 表示从头。 */
initialPos: number
}
class ReaderAudioEngine {
private audio: HTMLAudioElement
/** hls.js 实例(只用到 destroy)。 */
private hls: { destroy: () => void } | null = null
/** 元数据就绪后是否已经套用过进度/片头跳转。 */
private restored = false
/** 本章是否已经触发过片尾跳过,避免 timeupdate 反复触发。 */
private skippedEnd = false
/** 最近一次播放位置(秒):节流窗口外补报进度时要用。 */
private lastTime = 0
private lastSavedAt = 0
/** 上一次落库的进度标识(书|章|秒),相同则不重复请求。 */
private savedKey = ''
/** 定时关闭截止时间戳(毫秒),0 表示未开启。 */
private timerDeadline = 0
/** 暂停开始的毫秒时间戳,0 表示当前在播(定时倒计时只在播放中走)。 */
private pausedAt = 0
private timerTicker: number | null = null
/** 一次性的初始进度,等 metadata 就绪后消费。 */
private pendingInitialPos = 0
/** 是否已经套用过「上次的定时设置」,只在首次加载时套一次。 */
private timerBooted = false
/** 正在拆卸音源(避免拆卸触发的 pause 事件被当成用户暂停)。 */
private detaching = false
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
constructor() {
const audio = new Audio()
audio.preload = 'metadata'
this.audio = audio
this.bindAudio()
this.bindLifecycle()
this.bindSettings()
this.bindMediaSession()
}
// ─── 对外 API ─────────────────────────────────────────────────────────
/**
* 加载并播放一章。同一本书同一章重复调用(阅读页 effect 重跑、目录同步回写)
* 会被忽略,避免把正在播的进度重置掉。
*/
loadChapter(input: LoadChapterInput): void {
const s = useReaderAudioStore.getState()
if (
s.bookId === input.bookId &&
s.chapterIndex === input.chapterIndex &&
s.track === input.track &&
(s.status === 'loading' || s.status === 'playing' || s.status === 'paused')
) {
return
}
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
this.commitProgress()
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
useReaderAudioStore.setState({
bookId: input.bookId,
bookName: input.bookName,
cover: input.cover,
chapters: input.chapters,
chapterIndex: input.chapterIndex,
chapterTitle,
track: input.track,
transcoding: input.transcoding,
openCredits: input.openCredits,
closeCredits: input.closeCredits,
hasNext: this.computeHasNext(input.chapters, input.chapterIndex),
status: 'loading',
error: '',
cur: 0,
dur: 0,
})
this.restored = false
this.skippedEnd = false
this.lastTime = 0
this.lastSavedAt = 0
this.savedKey = ''
this.pendingInitialPos = Math.max(0, input.initialPos)
// 进场套用上次的定时设置(对应 legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
if (!this.timerBooted) {
this.timerBooted = true
const preset = useReaderSettingsStore.getState().audioTimerMinutes
if (preset > 0) this.applyTimer(preset)
}
void this.attachSource(input.track)
}
play(): void {
void this.audio.play().catch(() => {
// 自动播放被浏览器拦下(典型是 iOS):退回暂停态,让用户点按钮手动触发,
// 而不是一直停在「加载中」。
if (useReaderAudioStore.getState().status === 'loading') {
useReaderAudioStore.setState({ status: 'paused' })
}
})
}
pause(): void {
this.audio.pause()
// 还没触发 play 事件时(status 仍是 loading),下面的 pause 事件处理会跳过状态
// 判定;这里补一次,保证按钮不会一直转圈。
if (useReaderAudioStore.getState().status === 'loading') {
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
}
}
toggle(): void {
if (this.audio.paused) this.play()
else this.pause()
}
seekBy(delta: number): void {
const total = Number.isFinite(this.audio.duration)
? this.audio.duration
: this.audio.currentTime + Math.abs(delta)
this.seekTo(Math.min(Math.max(0, this.audio.currentTime + delta), Math.max(0, total - 0.2)))
}
seekTo(seconds: number): void {
if (!Number.isFinite(seconds)) return
this.audio.currentTime = Math.max(0, seconds)
this.lastTime = this.audio.currentTime
useReaderAudioStore.setState({ cur: this.audio.currentTime })
this.updateSessionPosition()
}
/** 倍速改动写回 settings store,订阅回调负责应用到 <audio>。 */
setSpeed(v: number): void {
useReaderSettingsStore.getState().setAudioSpeed(v)
}
/** 片头/片尾跳过秒数:立刻生效并按书持久化。 */
setCredits(open: number, close: number): void {
useReaderAudioStore.setState({ openCredits: open, closeCredits: close })
const { bookId } = useReaderAudioStore.getState()
if (!bookId) return
readerAPI.saveAudioConfig(bookId, { open_credits: open, close_credits: close }).catch(() => undefined)
}
/** 定时关闭:0 表示关闭定时。 */
applyTimer(minutes: number): void {
useReaderSettingsStore.getState().setAudioTimerMinutes(minutes)
useReaderAudioStore.setState({ timerPreset: minutes })
if (minutes <= 0) {
this.timerDeadline = 0
this.pausedAt = 0
this.stopTimerTicker()
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
return
}
this.timerDeadline = Date.now() + minutes * 60_000
// 暂停期间不倒计时:记下暂停起点,恢复播放时顺延截止时间
this.pausedAt = this.audio.paused ? Date.now() : 0
useReaderAudioStore.setState({ timerLeft: minutes * 60, timerActive: true })
this.startTimerTicker()
}
selectChapter(index: number): void {
void this.goToChapter(index, 0)
}
next(): void {
const i = this.neighborChapterIndex(1)
if (i === null) return
void this.goToChapter(i, 0)
}
prev(): void {
const i = this.neighborChapterIndex(-1)
if (i === null) return
void this.goToChapter(i, 0)
}
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
stop(): void {
this.commitProgress()
this.detachSource()
this.timerDeadline = 0
this.pausedAt = 0
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
this.timerBooted = false
this.stopTimerTicker()
this.setSessionMetadata(null)
this.setSessionPlaybackState('none')
resetReaderAudio()
}
/** 立即补报一次进度(不吃节流窗口)。 */
commitProgress(): void {
this.lastSavedAt = 0
this.persistProgress()
}
// ─── 音源装载 ─────────────────────────────────────────────────────────
private async attachSource(src: string): Promise<void> {
const token = ++this.loadToken
this.detachSource()
this.setSessionMetadata(src)
if (!src) {
this.fail('本章没有可播放的音频')
return
}
if (src.includes('.m3u8')) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
if (token !== this.loadToken) return
const Hls = mod.default
if (Hls.isSupported()) {
const inst = new Hls({ enableWorker: true })
inst.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) this.fail('音频加载失败,请检查网络或源文件是否还在')
})
inst.loadSource(src)
inst.attachMedia(this.audio)
this.hls = inst
return
}
// Safari 原生 HLS
this.audio.src = src
return
}
this.audio.src = src
}
private detachSource(): void {
this.detaching = true
this.hls?.destroy()
this.hls = null
this.audio.removeAttribute('src')
this.audio.load()
this.detaching = false
}
private async goToChapter(index: number, initialPos: number): Promise<void> {
const { bookId, chapters } = useReaderAudioStore.getState()
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
useReaderAudioStore.setState({ status: 'loading', error: '' })
try {
const ct = await readerAPI.bookContent(bookId, index)
const track = ct.tracks?.[0]
if (ct.type !== 'audio' || !track) {
this.fail('本章没有可播放的音频')
return
}
const s = useReaderAudioStore.getState()
this.loadChapter({
bookId,
bookName: s.bookName,
cover: s.cover,
chapters,
chapterIndex: index,
track,
transcoding: ct.transcoding ?? false,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
initialPos,
})
} catch (e) {
this.fail((e as Error).message || '章节加载失败')
}
}
private fail(message: string): void {
useReaderAudioStore.setState({ status: 'error', error: message })
this.setSessionPlaybackState('paused')
}
// ─── <audio> 事件 ─────────────────────────────────────────────────────
private bindAudio(): void {
const audio = this.audio
audio.addEventListener('play', () => {
useReaderAudioStore.setState({ status: 'playing' })
this.setSessionPlaybackState('playing')
// 恢复播放:把暂停时长顺延到定时截止时间
if (this.pausedAt > 0) {
this.timerDeadline += Date.now() - this.pausedAt
this.pausedAt = 0
}
})
audio.addEventListener('pause', () => {
if (this.detaching) return
// 只认「播放中 -> 暂停」这一种。换源/停止时 load() 也会抛出 pause 事件,
// 那些时刻 status 是 loading/idle,直接忽略;否则会把刚进入的加载态改成暂停,
// 并给新章节写一条位置为 0 的进度。
if (useReaderAudioStore.getState().status !== 'playing') return
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
this.commitProgress()
})
audio.addEventListener('ended', () => {
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
this.commitProgress()
if (useReaderAudioStore.getState().hasNext) {
this.next()
return
}
// 最后一章播完:ended 不会触发 pause,倒计时/暂停态要在这里自己落定
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
})
audio.addEventListener('loadedmetadata', () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
useReaderAudioStore.setState({ dur: total })
if (!this.restored) {
const { openCredits } = useReaderAudioStore.getState()
if (this.pendingInitialPos > 0 && (total === 0 || this.pendingInitialPos < total)) {
// 有进度:按存档续播(legado: position != 0 时不套用片头)
audio.currentTime = this.pendingInitialPos
useReaderAudioStore.setState({ cur: this.pendingInitialPos })
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
// 全新开播:跳到片头结束位置(legado: skipStartMs)
audio.currentTime = openCredits
useReaderAudioStore.setState({ cur: openCredits })
}
this.pendingInitialPos = 0
}
this.restored = true
audio.playbackRate = useReaderAudioStore.getState().speed
this.play()
})
audio.addEventListener('durationchange', () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
useReaderAudioStore.setState({ dur: total })
this.updateSessionPosition()
})
audio.addEventListener('timeupdate', () => {
const t = audio.currentTime
this.lastTime = t
useReaderAudioStore.setState({ cur: t })
this.updateSessionPosition()
this.reportProgress()
this.checkCreditsEnd()
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
this.checkTimer()
})
audio.addEventListener('error', () => {
const code = audio.error?.code
this.fail(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
: code === MediaError.MEDIA_ERR_NETWORK
? '音频加载失败,请检查网络或源文件是否还在'
: '音频无法播放',
)
})
}
// ─── 页面生命周期:只负责补报进度,绝不暂停播放 ───────────────────────
private bindLifecycle(): void {
const flush = () => this.commitProgress()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
window.addEventListener('pagehide', flush)
}
private bindSettings(): void {
const apply = () => {
const { audioSpeed } = useReaderSettingsStore.getState()
this.audio.playbackRate = audioSpeed
useReaderAudioStore.setState({ speed: audioSpeed })
}
apply()
// 引擎是常驻单例,订阅不需要解除
useReaderSettingsStore.subscribe(apply)
}
// ─── 进度 ─────────────────────────────────────────────────────────────
private reportProgress(): void {
const now = Date.now()
if (now - this.lastSavedAt < PROGRESS_SAVE_INTERVAL_MS) return
this.lastSavedAt = now
this.persistProgress()
}
private persistProgress(): void {
const { bookId, chapterIndex, chapterTitle } = useReaderAudioStore.getState()
if (!bookId || this.lastTime <= 0) return
const pos = Math.max(0, Math.floor(this.lastTime))
const key = `${bookId}|${chapterIndex}|${pos}`
if (key === this.savedKey) return
this.savedKey = key
readerAPI.saveProgress(bookId, { chapter_index: chapterIndex, pos, chapter_title: chapterTitle }).catch(() => undefined)
}
// ─── 片头片尾 ─────────────────────────────────────────────────────────
private checkCreditsEnd(): void {
const { closeCredits, hasNext } = useReaderAudioStore.getState()
const audio = this.audio
if (closeCredits <= 0 || this.skippedEnd) return
if (!Number.isFinite(audio.duration) || audio.duration <= closeCredits + 1) return
if (audio.currentTime < audio.duration - closeCredits) return
this.skippedEnd = true
if (!hasNext) {
audio.pause()
audio.currentTime = Math.max(0, audio.duration - 0.5)
return
}
this.next()
}
// ─── 定时关闭 ─────────────────────────────────────────────────────────
/** 剩余秒数:暂停期间以暂停起点为准,倒计时不会在暂停时溜走。 */
private timerLeftSeconds(): number {
if (this.timerDeadline === 0) return 0
const now = this.pausedAt > 0 ? this.pausedAt : Date.now()
return Math.max(0, Math.round((this.timerDeadline - now) / 1000))
}
private checkTimer(): void {
const s = useReaderAudioStore.getState()
if (!s.timerActive || this.timerDeadline === 0) return
const left = this.timerLeftSeconds()
if (left !== s.timerLeft) useReaderAudioStore.setState({ timerLeft: left })
// 暂停中不判到期:恢复播放时截止时间已经顺延过
if (left <= 0 && this.pausedAt === 0) {
this.timerDeadline = 0
this.stopTimerTicker()
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
this.audio.pause()
toast('定时结束,已暂停播放')
}
}
private startTimerTicker(): void {
this.stopTimerTicker()
this.timerTicker = window.setInterval(() => this.checkTimer(), 1000)
}
private stopTimerTicker(): void {
if (this.timerTicker !== null) {
window.clearInterval(this.timerTicker)
this.timerTicker = null
}
}
// ─── Media Session(锁屏 / 通知栏 / 耳机按键) ────────────────────────
private session(): MediaSession | null {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return null
return navigator.mediaSession
}
private setSessionMetadata(track: string | null): void {
const session = this.session()
if (!session || typeof MediaMetadata === 'undefined') return
if (track === null) {
session.metadata = null
return
}
const { bookName, chapterTitle, cover } = useReaderAudioStore.getState()
const coverSrc = readerCoverSrc(cover)
const artwork: MediaImage[] = coverSrc
? [{ src: new URL(coverSrc, window.location.origin).href, sizes: '512x512' }]
: []
try {
session.metadata = new MediaMetadata({
title: chapterTitle || bookName || 'MeBox 听书',
artist: bookName || 'MeBox',
album: 'MeBox 听书',
artwork,
})
} catch {
// 个别浏览器对 artwork 类型挑剔,失败不影响播放
}
}
private setSessionPlaybackState(state: MediaSessionPlaybackState): void {
const session = this.session()
if (!session) return
try {
session.playbackState = state
} catch {
// 忽略
}
}
private updateSessionPosition(): void {
const session = this.session()
if (!session || typeof session.setPositionState !== 'function') return
const { dur } = useReaderAudioStore.getState()
const position = this.audio.currentTime
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(position)) return
const rate = this.audio.playbackRate
try {
session.setPositionState({
duration: dur,
position: Math.min(Math.max(0, position), dur),
playbackRate: Number.isFinite(rate) && rate > 0 ? rate : 1,
})
} catch {
// position 越界等情况下浏览器会抛错,忽略
}
}
private bindMediaSession(): void {
const session = this.session()
if (!session) return
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
['play', () => this.play()],
['pause', () => this.pause()],
['stop', () => this.stop()],
['seekbackward', () => this.seekBy(-SEEK_STEP)],
['seekforward', () => this.seekBy(SEEK_STEP)],
['previoustrack', () => this.prev()],
['nexttrack', () => this.next()],
[
'seekto',
(details) => {
if (typeof details.seekTime === 'number') this.seekTo(details.seekTime)
},
],
]
for (const [action, handler] of handlers) {
try {
session.setActionHandler(action, handler)
} catch {
// 该动作在当前浏览器不支持时会抛错,逐个忽略
}
}
}
// ─── 工具 ─────────────────────────────────────────────────────────────
private neighborChapterIndex(dir: 1 | -1): number | null {
const { chapters, chapterIndex } = useReaderAudioStore.getState()
for (let i = chapterIndex + dir; i >= 0 && i < chapters.length; i += dir) {
if (!chapters[i].is_volume) return i
}
return null
}
private computeHasNext(chapters: ReaderChapter[], index: number): boolean {
for (let i = index + 1; i < chapters.length; i++) {
if (!chapters[i].is_volume) return true
}
return false
}
}
let engine: ReaderAudioEngine | null = null
/**
* 取全局听书引擎(首次调用时才构造,避免没听过书的应用也创建一个 <audio>)。
*/
export function getReaderAudioEngine(): ReaderAudioEngine {
if (!engine) engine = new ReaderAudioEngine()
return engine
}
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand'
import type { ReaderChapter } from '../api/reader'
// 听书全局播放状态(刻意不持久化)。
//
// 真正的播放器是模块级单例(见 pages/reader/readerAudioEngine.ts),React 组件只
// 读写这份状态、通过引擎的方法控制播放。这样音频不依附于任何路由:离开阅读页、
// 换到别的书、甚至回到影视首页,播放都继续,由 Media Session 提供锁屏/通知栏控制。
export type ReaderAudioStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
export interface ReaderAudioState {
/** 当前音轨所属书籍;空串表示没有加载任何音轨。 */
bookId: string
bookName: string
cover: string
chapterIndex: number
chapterTitle: string
chapters: ReaderChapter[]
/** 当前音轨地址(已签名,可直接交给 <audio> 或 hls.js)。 */
track: string
/** 该音轨由服务端转码,首次播放需要等待转码完成。 */
transcoding: boolean
openCredits: number
closeCredits: number
hasNext: boolean
status: ReaderAudioStatus
/** 加载/播放失败的可见原因(空串表示无错误)。 */
error: string
/** 当前播放位置(秒)。 */
cur: number
/** 总时长(秒),元数据未就绪时为 0。 */
dur: number
/** 播放倍速,与 readerSettings 的 audioSpeed 保持一致。 */
speed: number
timerActive: boolean
/** 定时剩余秒数,0 表示未开启。 */
timerLeft: number
timerPreset: number
}
const initialState: ReaderAudioState = {
bookId: '',
bookName: '',
cover: '',
chapterIndex: 0,
chapterTitle: '',
chapters: [],
track: '',
transcoding: false,
openCredits: 0,
closeCredits: 0,
hasNext: false,
status: 'idle',
error: '',
cur: 0,
dur: 0,
speed: 1,
timerActive: false,
timerLeft: 0,
timerPreset: 0,
}
export const useReaderAudioStore = create<ReaderAudioState>(() => ({ ...initialState }))
/** 引擎专用:整块重置回空闲态(停止播放时调用)。 */
export function resetReaderAudio(): void {
useReaderAudioStore.setState({ ...initialState })
}
+49 -2
View File
@@ -45,6 +45,30 @@ export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
/**
* 漫画图片的显示尺寸档位(上下滚动模式用)。
*
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
* 和 comicDoublePage 一样只存本机、不参与账号同步。
*
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
* - `long` 适应长边:整页完整可见,宽高都不超出视口
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
*/
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
{ id: 'default', label: '默认' },
{ id: 'width', label: '适应宽度' },
{ id: 'height', label: '适应高度' },
{ id: 'long', label: '适应长边' },
{ id: 'original', label: '原图' },
]
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
@@ -81,6 +105,19 @@ interface ReaderSettingsState {
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
comicImageFit: ComicImageFit
setComicImageFit: (fit: ComicImageFit) => void
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
/**
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
*/
searchScopeIds: string[]
setSearchScopeIds: (ids: string[]) => void
fontSize: number
setFontSize: (size: number) => void
@@ -147,6 +184,15 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
// 点「原图」。
comicImageFit: 'default',
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
searchScopeIds: [],
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
@@ -173,8 +219,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
* 取决于屏幕宽度和分辨率。
*/
export interface ReaderSettingsProfile {
theme_id: string
+55
View File
@@ -0,0 +1,55 @@
import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
import type { ComicImageFit } from '../stores/readerSettings'
// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`comicImageFit: ${name}`)
}
// ── 是否由图片自身约束尺寸 ──
const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
check('height 是自尺寸档位', isSelfSizedComicFit('height'))
check('long 是自尺寸档位', isSelfSizedComicFit('long'))
check('original 是自尺寸档位', isSelfSizedComicFit('original'))
check(
'自尺寸档位与列宽档位互斥且覆盖全部取值',
ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
)
// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
for (const fit of ['default', 'width'] as ComicImageFit[]) {
const loaded = comicImageSizing(fit, 800, true)
check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
}
// ── height:一屏一页 ──
check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
// ── long:整页完整可见 ──
check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
// ── original:原始像素 1:1 ──
check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
for (const fit of ['height', 'long'] as ComicImageFit[]) {
check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
}
check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
+59
View File
@@ -0,0 +1,59 @@
import type { CSSProperties } from 'react'
import type { ComicImageFit } from '../stores/readerSettings'
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 class/style(纯逻辑,便于单测)。
//
// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
//
// 两种控制方式:
// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
const PLACEHOLDER_MIN_HEIGHT = '10rem'
export interface ComicImageSizing {
className: string
style: CSSProperties
}
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
return fit === 'height' || fit === 'long' || fit === 'original'
}
/**
* 滚动模式下漫画图片的尺寸样式。
*
* @param fit 显示尺寸档位。
* @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
* (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
* @param loaded 图片是否已加载完成,未完成时给个占位高度。
*/
export function comicImageSizing(
fit: ComicImageFit,
viewportHeight: number,
loaded: boolean,
): ComicImageSizing {
const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
// height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
switch (fit) {
case 'height':
// 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
case 'long':
// 整页完整可见:长边贴合视口,宽高都不超出
return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
case 'original':
// 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
default:
// default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
return { className: 'block w-full', style: placeholder }
}
}
+5 -4
View File
@@ -66,13 +66,14 @@ const noGroups = buildBookGroupTabs(books, [])
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
const tabs = buildBookGroupTabs(books, groups)
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
check('tabs = 全部 + groups + 未分组(未分组殿后)', tabs.map((t) => t.name).join(',') === '全部,科幻,在读,未分组')
check('全部 counts all books', tabs[0].count === 4)
check('未分组 counts unclaimed', tabs[1].count === 1)
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
check('ungrouped is the last tab', tabs[tabs.length - 1].kind === 'ungrouped')
check('未分组 counts unclaimed', tabs[3].count === 1)
check('group counts only existing books', tabs[1].count === 2 && tabs[2].count === 1)
check('stale ids do not inflate counts', (() => {
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
return withStale[2].count === 1 && withStale[1].count === 3
return withStale[1].count === 1 && withStale[2].count === 3
})())
// ── 排序与选中回落 ──
+9 -7
View File
@@ -204,7 +204,9 @@ export function filterBooksByGroup<T extends { id: string }>(
}
/**
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
* 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。
* 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会
* 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
@@ -219,12 +221,6 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
]
if (groups.length === 0) return tabs
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
for (const group of groups) {
tabs.push({
id: group.name,
@@ -233,6 +229,12 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
kind: 'group',
})
}
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
return tabs
}