From 6584519bd8d379146fc7e2bf719cb365ab6432c3 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:29:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/reader_routes.go | 24 ++ internal/service/reader/cover_url_test.go | 38 +++ internal/service/reader/reader.go | 120 +++++++++- internal/service/reader/rule/analyzer.go | 25 +- .../reader/rule/analyzer_url_fallback_test.go | 60 +++++ .../service/reader/rule/inner_rule_test.go | 58 +++++ internal/service/reader/search_merge_test.go | 73 ++++++ internal/service/reader/switch_origin_test.go | 219 ++++++++++++++++++ web/src/api/reader.ts | 6 + web/src/components/LayoutHeaderSections.tsx | 17 +- web/src/components/ReaderBookCover.tsx | 46 ++++ web/src/pages/reader/ReaderBookPage.tsx | 99 +++++++- web/src/pages/reader/ReaderHomeContent.tsx | 15 +- web/src/pages/reader/ReaderSearchPage.tsx | 78 ++++--- web/src/pages/reader/ReaderViewPage.tsx | 87 ++++++- web/src/pages/reader/SourcePickerDialog.tsx | 105 +++++++++ web/src/utils/coverUrl.test.ts | 30 +++ web/src/utils/coverUrl.ts | 20 ++ web/src/utils/kindTags.test.ts | 20 ++ web/src/utils/kindTags.ts | 11 + 20 files changed, 1072 insertions(+), 79 deletions(-) create mode 100644 internal/service/reader/cover_url_test.go create mode 100644 internal/service/reader/rule/analyzer_url_fallback_test.go create mode 100644 internal/service/reader/rule/inner_rule_test.go create mode 100644 internal/service/reader/search_merge_test.go create mode 100644 internal/service/reader/switch_origin_test.go create mode 100644 web/src/components/ReaderBookCover.tsx create mode 100644 web/src/pages/reader/SourcePickerDialog.tsx create mode 100644 web/src/utils/coverUrl.test.ts create mode 100644 web/src/utils/coverUrl.ts create mode 100644 web/src/utils/kindTags.test.ts create mode 100644 web/src/utils/kindTags.ts diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 17132df..23cdee7 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -57,6 +57,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) { g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc)) g.DELETE("/books/:id", readerRemoveBookHandler(svc)) g.PUT("/books/:id/progress", readerSaveProgressHandler(svc)) + // 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建) + g.POST("/books/:id/origin", readerSwitchOriginHandler(svc)) g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc)) g.GET("/books/:id/chapters", readerListChaptersHandler(svc)) g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc)) @@ -414,6 +416,28 @@ func readerAddBookHandler(svc *service.Container) gin.HandlerFunc { } } +// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。 +// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。 +func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc { + var body struct { + Origin reader.SearchOrigin `json:"origin" binding:"required"` + } + 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) + book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book) + c.JSON(http.StatusOK, book) + } +} + // readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。 // 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。 func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/service/reader/cover_url_test.go b/internal/service/reader/cover_url_test.go new file mode 100644 index 0000000..a76a90b --- /dev/null +++ b/internal/service/reader/cover_url_test.go @@ -0,0 +1,38 @@ +package reader + +import "testing" + +// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回, +// normalizeCoverURL 必须拦住这类非图片地址,避免前端 显示破图。 +func TestNormalizeCoverURL(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"blank", " ", ""}, + {"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"}, + {"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"}, + {"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"}, + {"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"}, + {"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="}, + {"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"}, + { + "search params envelope", + "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==", + "", + }, + {"text data uri", "data:text/plain;base64,aGk=", ""}, + {"source name", "光遇聚合", ""}, + {"javascript url", "javascript:alert(1)", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeCoverURL(tc.in); got != tc.want { + t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index e1e134f..ba38f0e 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -718,6 +718,9 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook { k := b.Name + "|" + b.Author if existing, ok := merged[k]; ok { existing.Origins = append(existing.Origins, b.Origins...) + // 同书多源时补齐缺失字段:legado 是「一源一行」,各源自己显示拿到的 + // 信息;MeBox 合并成一行,若不补齐,先到的空值会挡掉后面源的有效值。 + fillMissingSearchBookFields(existing, &b) continue } merged[k] = &b @@ -739,6 +742,29 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook { return out } +// fillMissingSearchBookFields 把同书多源结果里另一份的有效字段补进合并结果。 +// +// 同一个聚合源的不同上游、或不同书源,对同一本书的覆盖能力不同:有的能给封面、 +// 简介、字数、最新章节,有的只给书名。合并成一行时必须补齐,否则先到的空值会把 +// 后面源的有效值挡掉——典型表现就是「这本书明明有源带封面,列表里却是空白」。 +func fillMissingSearchBookFields(dst, src *SearchBook) { + if dst.CoverURL == "" { + dst.CoverURL = src.CoverURL + } + if dst.Intro == "" { + dst.Intro = src.Intro + } + if dst.Kind == "" { + dst.Kind = src.Kind + } + if dst.WordCount == "" { + dst.WordCount = src.WordCount + } + if dst.LatestChapter == "" { + dst.LatestChapter = src.LatestChapter + } +} + // searchInSource 单源搜索(对应 WebBook.searchBook)。 func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) ([]SearchBook, error) { if bs == nil { @@ -800,7 +826,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo WordCount: wordCount, LatestChapter: lastChapter, Intro: intro, - CoverURL: cover, + CoverURL: normalizeCoverURL(cover), BookURL: bookURL, Origins: []SearchOrigin{{ SourceID: src.ID, @@ -835,6 +861,33 @@ func firstNonEmpty(vals ...string) string { return "" } +// normalizeCoverURL 过滤书源规则给出的封面地址,只保留浏览器能真正渲染成图片的 +// 值,其余一律返回空串(前端据此显示占位图标,而不是破图)。 +// +// 背景:书源在「这本书没有封面」时不一定返回空值。legado 语义下 +// getString(rule, isUrl=true) 取值为空会回退成 baseUrl,而聚合类书源 +// (如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封, +// 于是封面上会落一串 data: 文本, 按 text/plain 处理必然破图。 +// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面 +// (/api/reader/local/asset?...)。 +func normalizeCoverURL(raw string) string { + u := strings.TrimSpace(raw) + if u == "" { + return "" + } + lower := strings.ToLower(u) + switch { + case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"): + return u + case strings.HasPrefix(lower, "data:image/"): + return u + case strings.HasPrefix(u, "/"): + return u + default: + return "" + } +} + // ─── 详情 / 目录 / 正文 ───────────────────────────────────────────────────── // BookInfo 书籍详情(对应 legado Book 信息页)。 @@ -912,7 +965,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo info.Intro = v } if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" { - info.CoverURL = v + info.CoverURL = normalizeCoverURL(v) } if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" { info.TocURL = v @@ -1300,7 +1353,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc BookURL: origin.BookURL, Name: name, Author: author, - CoverURL: coverURL, + CoverURL: normalizeCoverURL(coverURL), Type: origin.OriginType, } if err := s.repo.CreateBook(ctx, book); err != nil { @@ -1309,6 +1362,67 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc return book, nil } +// SwitchOrigin 换源:把书架里的书切到另一个书源(对应 legado 的「换源」)。 +// +// 换源要同时处理三件事: +// 1. 来源字段整体换成新源(origin / origin_name / book_url / toc_url); +// 2. 旧源缓存的目录必须清掉——章节地址只对旧源有效,留着会让阅读器读到错内容; +// 3. 阅读进度保留(按章节序号定位,与 legado 的做法一致)。 +// +// 新源详情是「尽力而为」:抓得到就用它的 tocUrl(以及当前封面为空时的封面), +// 抓不到就把 tocUrl 留空让目录回落到书籍地址重新解析,不让一次网络抖动挡住换源。 +func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string, origin SearchOrigin) (*model.ReaderBook, error) { + book, err := s.repo.GetBook(ctx, bookID) + if err != nil { + return nil, err + } + if book.UserID != userID { + return nil, fmt.Errorf("无权操作他人书架") + } + if book.LocalPath != "" { + return nil, fmt.Errorf("本地导入的书籍没有书源,无法换源") + } + target := strings.TrimSpace(origin.BookURL) + if target == "" { + return nil, fmt.Errorf("缺少目标书源的书本地址") + } + if _, _, err := s.loadSourceFlexible(ctx, origin.SourceID, origin.Origin); err != nil { + return nil, err + } + if book.BookURL == target && book.Origin == origin.Origin { + return book, nil // 已经是这个源,重复点击视为成功 + } + + info, infoErr := s.GetBookInfo(ctx, origin.SourceID, origin.Origin, target) + if infoErr != nil && s.log != nil { + s.log.Warn("reader: 换源时读取新源详情失败", + zap.String("book", book.ID), zap.String("origin", origin.Origin), zap.Error(infoErr)) + } + + book.Origin = origin.Origin + book.OriginName = firstNonEmpty(origin.OriginName, book.OriginName) + book.BookURL = target + book.TocURL = "" + if info != nil { + book.TocURL = strings.TrimSpace(info.TocURL) + if book.CoverURL == "" { + book.CoverURL = normalizeCoverURL(info.CoverURL) + } + if info.LatestChapter != "" { + book.LatestChapterTitle = info.LatestChapter + } + } + // 目录是旧源的缓存,必须清空;章节数一并归零,等新源目录重新预热后再算未读。 + book.TotalChapterNum = 0 + if err := s.repo.ReplaceChapters(ctx, book.ID, nil); err != nil { + return nil, err + } + if err := s.repo.UpdateBook(ctx, book); err != nil { + return nil, err + } + return book, nil +} + // GetBook 按 ID 取书(媒体代理等使用)。 func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) { return s.repo.GetBook(ctx, id) diff --git a/internal/service/reader/rule/analyzer.go b/internal/service/reader/rule/analyzer.go index 20d1111..5c22694 100644 --- a/internal/service/reader/rule/analyzer.go +++ b/internal/service/reader/rule/analyzer.go @@ -248,10 +248,16 @@ func (a *AnalyzeRule) putRule(putMap map[string]string) error { return nil } -func (a *AnalyzeRule) makeDeps() *RuleDeps { +// makeDeps 构造 MakeUpRule 展开内嵌 {{...}} 需要的执行环境。 +// +// content 是当前正在解析的元素。legado 的内嵌规则({{$.x}} / {{@x}})在**当前元素** +// 上求值:书源普遍用 `{{$.status}},{{$.score}}` 这种模板拼 kind、用 +// `{{$.source}} {{$.last_chapter_title}}` 拼最新章节。若拿整份响应去求值, +// 这些模板会全部取空——表现为 kind=",,,"、"最新"为空(聚合源尤其明显)。 +func (a *AnalyzeRule) makeDeps(content any) *RuleDeps { return &RuleDeps{ JS: a.evalJS, - Rule: func(rule string) (string, error) { return a.GetString(rule, nil, false) }, + Rule: func(rule string) (string, error) { return a.GetString(rule, content, false) }, Get: a.Get, } } @@ -279,7 +285,7 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i if err := a.putRule(sourceRule.putMap); err != nil { return nil, err } - resolved, err := sourceRule.MakeUpRule(result, a.makeDeps()) + resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result)) if err != nil { return nil, err } @@ -378,7 +384,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl if err := a.putRule(sourceRule.putMap); err != nil { return "", err } - resolved, err := sourceRule.MakeUpRule(result, a.makeDeps()) + resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result)) if err != nil { return "", err } @@ -423,7 +429,14 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl } if isUrl { if strings.TrimSpace(str) == "" { - return a.baseUrl, nil + // 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。 + // 但 baseUrl 未必是地址:聚合类书源(如「光遇聚合」)的搜索请求地址 + // 本身就是 data:;base64,... 参数信封,直接回退会把信封当成封面/书址 + // 返回,前端 只能显示破图。故仅在 baseUrl 是 http(s) 地址时回退。 + if base := strings.TrimSpace(a.baseUrl); isAbsURL(base) { + return base, nil + } + return "", nil } return a.absolutize(str), nil } @@ -444,7 +457,7 @@ func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) { if err := a.putRule(sourceRule.putMap); err != nil { return nil, err } - resolved, err := sourceRule.MakeUpRule(result, a.makeDeps()) + resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result)) if err != nil { return nil, err } diff --git a/internal/service/reader/rule/analyzer_url_fallback_test.go b/internal/service/reader/rule/analyzer_url_fallback_test.go new file mode 100644 index 0000000..4a028fc --- /dev/null +++ b/internal/service/reader/rule/analyzer_url_fallback_test.go @@ -0,0 +1,60 @@ +package rule + +import "testing" + +// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。 +// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址, +// 回退结果会把封面/书址污染成非图片文本(前端 直接破图)。 +func TestGetStringURLFallbackSkipsNonHTTPBaseURL(t *testing.T) { + const payload = `{"data":[{"book_name":"全球高武","author":"老鹰吃小鸡"}]}` + const envelope = "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==" + + elementOf := func(t *testing.T, content, baseURL string) (*AnalyzeRule, any) { + t.Helper() + a := NewAnalyzeRule() + a.SetContent(content, baseURL) + els, err := a.GetElements("$.data") + if err != nil { + t.Fatalf("GetElements: %v", err) + } + if len(els) != 1 { + t.Fatalf("GetElements: want 1 element, got %d", len(els)) + } + return a, els[0] + } + + t.Run("空取值不会把 data: 信封当地址返回", func(t *testing.T) { + a, el := elementOf(t, payload, envelope) + got, err := a.GetString("$.thumb_url", el, true) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != "" { + t.Fatalf("封面应为空,实际 = %q", got) + } + }) + + t.Run("baseUrl 是 http(s) 时仍按 legado 语义回退", func(t *testing.T) { + const base = "https://www.example.com/search?q=x" + a, el := elementOf(t, payload, base) + got, err := a.GetString("$.thumb_url", el, true) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != base { + t.Fatalf("want base fallback %q, got %q", base, got) + } + }) + + t.Run("规则有取值时原样返回", func(t *testing.T) { + const withCover = `{"data":[{"book_name":"书","thumb_url":"https://img.example.com/c.jpg"}]}` + a, el := elementOf(t, withCover, envelope) + got, err := a.GetString("$.thumb_url", el, true) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != "https://img.example.com/c.jpg" { + t.Fatalf("封面 = %q", got) + } + }) +} diff --git a/internal/service/reader/rule/inner_rule_test.go b/internal/service/reader/rule/inner_rule_test.go new file mode 100644 index 0000000..d00e32c --- /dev/null +++ b/internal/service/reader/rule/inner_rule_test.go @@ -0,0 +1,58 @@ +package rule + +import "testing" + +// 书源普遍用 legado 的内嵌模板拼字段(聚合源的 kind / 最新章节就是这种写法): +// +// kind = "{{$.status}},{{$.score}},{{$.tags}}" +// lastChapter = "{{$.source}} {{$.last_chapter_title}}" +// +// 模板必须在**当前元素**上求值。修复前这里拿整份响应求值,模板一律取空, +// 表现为 kind=",,,"、最新章节为空——搜索结果和换源列表里所有条目长得一模一样。 +func TestInnerRuleTemplateEvaluatesAgainstElement(t *testing.T) { + const body = `{"data":[{"book_name":"全球高武","status":"连载","score":"9.2","tags":"都市","source":"svip_酷我","last_chapter_title":"第694章"}]}` + a := NewAnalyzeRule() + a.SetContent(body, "") + els, err := a.GetElements("$.data") + if err != nil { + t.Fatalf("GetElements: %v", err) + } + if len(els) != 1 { + t.Fatalf("want 1 element, got %d", len(els)) + } + el := els[0] + + got, err := a.GetString("{{$.status}},{{$.score}},{{$.tags}}", el, false) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != "连载,9.2,都市" { + t.Fatalf("kind 模板 = %q, want 连载,9.2,都市", got) + } + + got, err = a.GetString("{{$.source}} {{$.last_chapter_title}}", el, false) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != "svip_酷我 第694章" { + t.Fatalf("最新章节模板 = %q, want svip_酷我 第694章", got) + } + + // 列表规则(GetStringList)走的是同一套 MakeUpRule,必须同样生效 + list, err := a.GetStringList("{{$.status}},{{$.score}}", el, false) + if err != nil { + t.Fatalf("GetStringList: %v", err) + } + if len(list) != 1 || list[0] != "连载,9.2" { + t.Fatalf("列表模板 = %v, want [连载,9.2]", list) + } + + // 模板里取不存在的字段应得到空串,而不是报错 + got, err = a.GetString("[{{$.not_exist}}]", el, false) + if err != nil { + t.Fatalf("GetString: %v", err) + } + if got != "[]" { + t.Fatalf("缺失字段 = %q, want []", got) + } +} diff --git a/internal/service/reader/search_merge_test.go b/internal/service/reader/search_merge_test.go new file mode 100644 index 0000000..d60bf16 --- /dev/null +++ b/internal/service/reader/search_merge_test.go @@ -0,0 +1,73 @@ +package reader + +import "testing" + +// 同书多源合并:先到的源没有封面/简介时,必须用后面源的有效值补齐。 +// 否则「有源带封面」的书在合并成一行后会是空白(legado 一源一行则不会出现)。 +func TestMergeSearchResultsFillsMissingFields(t *testing.T) { + hits := []searchHit{ + {book: SearchBook{ + Name: "全球高武", + Author: "蛮荒帝霸", + CoverURL: "", + Origins: []SearchOrigin{{OriginName: "搜书神器", BookURL: "/a"}}, + }}, + {book: SearchBook{ + Name: "全球高武", + Author: "蛮荒帝霸", + CoverURL: "https://res.chuangke.tv/qk/246/a3/76/2271.jpg", + Intro: "一句话简介", + Kind: "连载", + WordCount: "177.2万字", + LatestChapter: "第694章", + Origins: []SearchOrigin{{OriginName: "svip_AU文学", BookURL: "/b"}}, + }}, + } + + got := mergeSearchResults(hits, "全球高武") + if len(got) != 1 { + t.Fatalf("合并后应只有 1 条,实际 %d", len(got)) + } + book := got[0] + if book.CoverURL != "https://res.chuangke.tv/qk/246/a3/76/2271.jpg" { + t.Fatalf("封面未从其它源补齐: %q", book.CoverURL) + } + if book.Intro != "一句话简介" { + t.Fatalf("简介未补齐: %q", book.Intro) + } + if book.Kind != "连载" || book.WordCount != "177.2万字" || book.LatestChapter != "第694章" { + t.Fatalf("字段未补齐: %+v", book) + } + if len(book.Origins) != 2 { + t.Fatalf("源数 = %d, want 2", len(book.Origins)) + } +} + +// 已经有值的字段不能被后来的源覆盖(先到的更权威,且保证结果稳定)。 +func TestMergeSearchResultsKeepsExistingFields(t *testing.T) { + hits := []searchHit{ + {book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://a.example.com/1.jpg", Intro: "甲的简介"}}, + {book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://b.example.com/2.jpg", Intro: "乙的简介"}}, + } + + got := mergeSearchResults(hits, "书") + if len(got) != 1 { + t.Fatalf("合并后应只有 1 条,实际 %d", len(got)) + } + if got[0].CoverURL != "https://a.example.com/1.jpg" || got[0].Intro != "甲的简介" { + t.Fatalf("不该被后来的源覆盖: %+v", got[0]) + } +} + +// 无书名的结果直接丢弃,且不会参与合并。 +func TestMergeSearchResultsSkipsEmptyName(t *testing.T) { + hits := []searchHit{ + {book: SearchBook{Name: "", Author: "作者", CoverURL: "https://a.example.com/1.jpg"}}, + {book: SearchBook{Name: "书", Author: "作者"}}, + } + + got := mergeSearchResults(hits, "书") + if len(got) != 1 || got[0].Name != "书" { + t.Fatalf("结果 = %+v", got) + } +} diff --git a/internal/service/reader/switch_origin_test.go b/internal/service/reader/switch_origin_test.go new file mode 100644 index 0000000..bddc5bd --- /dev/null +++ b/internal/service/reader/switch_origin_test.go @@ -0,0 +1,219 @@ +package reader + +import ( + "fmt" + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。 +func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string { + t.Helper() + if _, err := svc.ImportSources(t.Context(), raw); err != nil { + t.Fatalf("导入书源失败: %v", err) + } + srcs, err := svc.ListSources(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, s := range srcs { + if s.SourceURL == sourceURL { + return s.ID + } + } + t.Fatalf("导入后找不到书源 %s", sourceURL) + return "" +} + +// 换源:来源字段整体换到新源,旧源目录缓存清空,阅读进度保留。 +func TestSwitchOriginKeepsProgressAndClearsChapters(t *testing.T) { + oldSrv := e2eServer() + defer oldSrv.Close() + newSrv := e2eServer() + defer newSrv.Close() + + svc, repos := newLoginTestService(t) + ctx := t.Context() + oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL) + newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL) + + book, err := svc.AddBook(ctx, "u1", SearchOrigin{ + SourceID: oldID, + Origin: oldSrv.URL, + OriginName: "旧源", + BookURL: oldSrv.URL + "/book/1", + }, "斗破苍穹", "天蚕土豆", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + + // 旧源目录缓存 + 阅读进度 + chapters := make([]ChapterInput, 0, 5) + for i := 0; i < 5; i++ { + chapters = append(chapters, ChapterInput{ + Index: i, + Title: fmt.Sprintf("第 %d 章", i+1), + URL: fmt.Sprintf("%s/book/1/c%d.html", oldSrv.URL, i), + }) + } + if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil { + t.Fatalf("保存目录失败: %v", err) + } + if err := svc.SaveProgress(ctx, "u1", book.ID, 3, 120, "第 4 章"); err != nil { + t.Fatalf("保存进度失败: %v", err) + } + + switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{ + SourceID: newID, + Origin: newSrv.URL, + OriginName: "新源", + BookURL: newSrv.URL + "/book/1", + }) + if err != nil { + t.Fatalf("换源失败: %v", err) + } + + if switched.Origin != newSrv.URL || switched.OriginName != "新源" { + t.Fatalf("来源未切换: origin=%q name=%q", switched.Origin, switched.OriginName) + } + if switched.BookURL != newSrv.URL+"/book/1" { + t.Fatalf("书本地址未切换: %q", switched.BookURL) + } + if !strings.HasPrefix(switched.TocURL, newSrv.URL) { + t.Fatalf("tocUrl 应指向新源: %q", switched.TocURL) + } + if switched.TotalChapterNum != 0 { + t.Fatalf("旧源章数应归零,实际 %d", switched.TotalChapterNum) + } + if switched.DurChapterIndex != 3 || switched.DurChapterPos != 120 || switched.DurChapterTitle != "第 4 章" { + t.Fatalf("阅读进度没保留: %+v", switched) + } + + // 旧源章节缓存必须清掉(章节地址只对旧源有效) + left, err := repos.Reader.ListChapters(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Fatalf("换源后仍残留 %d 条旧源章节", len(left)) + } + + // 落库确认,避免只改了内存对象 + stored, err := repos.Reader.GetBook(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if stored.Origin != newSrv.URL || stored.BookURL != newSrv.URL+"/book/1" || stored.DurChapterIndex != 3 { + t.Fatalf("换源结果未落库: %+v", stored) + } +} + +// 空封面时用新源的封面补上;已有封面不被覆盖。 +func TestSwitchOriginFillsOnlyEmptyCover(t *testing.T) { + oldSrv := e2eServer() + defer oldSrv.Close() + newSrv := e2eServer() + defer newSrv.Close() + + svc, repos := newLoginTestService(t) + ctx := t.Context() + oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL) + newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL) + + book, err := svc.AddBook(ctx, "u1", SearchOrigin{ + SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1", + }, "斗破苍穹", "天蚕土豆", "https://old.example.com/cover.jpg") + if err != nil { + t.Fatal(err) + } + target := SearchOrigin{SourceID: newID, Origin: newSrv.URL, OriginName: "新源", BookURL: newSrv.URL + "/book/1"} + + switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, target) + if err != nil { + t.Fatalf("换源失败: %v", err) + } + if switched.CoverURL != "https://old.example.com/cover.jpg" { + t.Fatalf("已有封面不该被覆盖: %q", switched.CoverURL) + } + + // 清空封面后换源,应补上新源的封面(e2e 详情页没配封面规则,这里改为一本空封面的书) + switched.CoverURL = "" + if err := repos.Reader.UpdateBook(ctx, switched); err != nil { + t.Fatal(err) + } + back, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{ + SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1", + }) + if err != nil { + t.Fatalf("换回旧源失败: %v", err) + } + // e2e 书源没有 coverUrl 规则,所以这里只断言流程走通且不报错 + if back.Origin != oldSrv.URL { + t.Fatalf("换回失败: %+v", back) + } +} + +// 换源的各种拒绝场景。 +func TestSwitchOriginRejects(t *testing.T) { + srv := e2eServer() + defer srv.Close() + + svc, repos := newLoginTestService(t) + ctx := t.Context() + srcID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + + book, err := svc.AddBook(ctx, "u1", SearchOrigin{ + SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1", + }, "斗破苍穹", "天蚕土豆", "") + if err != nil { + t.Fatal(err) + } + valid := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/2"} + + t.Run("他人书架", func(t *testing.T) { + if _, err := svc.SwitchOrigin(ctx, "u2", book.ID, valid); err == nil || !strings.Contains(err.Error(), "无权") { + t.Fatalf("err = %v, want 无权操作他人书架", err) + } + }) + + t.Run("本地书没有书源", func(t *testing.T) { + local := &model.ReaderBook{ + UserID: "u3", + Name: "本地书", + LocalPath: "abc.txt", + BookURL: "local://abc", + } + if err := repos.Reader.CreateBook(ctx, local); err != nil { + t.Fatal(err) + } + if _, err := svc.SwitchOrigin(ctx, "u3", local.ID, valid); err == nil || !strings.Contains(err.Error(), "本地导入") { + t.Fatalf("err = %v, want 本地书不可换源", err) + } + }) + + t.Run("缺少目标书本地址", func(t *testing.T) { + if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{Origin: srv.URL}); err == nil { + t.Fatal("缺少 book_url 时应报错") + } + }) + + t.Run("书源不存在", func(t *testing.T) { + bad := SearchOrigin{Origin: "https://nope.example.com", BookURL: "https://nope.example.com/book/1"} + if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, bad); err == nil { + t.Fatal("书源不存在时应报错") + } + }) + + t.Run("同一源重复换源视为成功", func(t *testing.T) { + same := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1"} + got, err := svc.SwitchOrigin(ctx, "u1", book.ID, same) + if err != nil { + t.Fatalf("重复换源不该报错: %v", err) + } + if got.BookURL != srv.URL+"/book/1" { + t.Fatalf("书本地址被改动: %q", got.BookURL) + } + }) +} diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index cf7cf51..76eadce 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -283,6 +283,12 @@ export const readerAPI = { .post('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT }) .then((r) => r.data), removeBook: (id: string) => api.delete(`/reader/books/${id}`), + /** + * 换源:把书架里的书切到另一个书源。 + * 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。 + */ + switchOrigin: (id: string, origin: ReaderSearchOrigin) => + api.post(`/reader/books/${id}/origin`, { origin }, longOpts).then((r) => r.data), saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) => api.put(`/reader/books/${id}/progress`, body), // 听书跳过片头/片尾(秒,0 不跳过) diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index 8e2bfc6..8827e8e 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' +import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' import { ARTWORK, imageURL } from '../api/client' import { mediaAPI } from '../api/library' @@ -12,6 +12,7 @@ import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' import { LayoutReaderModeToggle } from './LayoutReaderModeToggle' +import ReaderBookCover from './ReaderBookCover' import type { useLayoutProfiles } from './useLayoutProfiles' import type { ThemeMode, useThemeMode } from './useThemeMode' @@ -248,19 +249,7 @@ function LayoutHeaderBookSearch() { className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group" >
- {book.cover_url ? ( - - ) : ( -
- -
- )} +
diff --git a/web/src/components/ReaderBookCover.tsx b/web/src/components/ReaderBookCover.tsx new file mode 100644 index 0000000..1d7f792 --- /dev/null +++ b/web/src/components/ReaderBookCover.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { BookOpen } from 'lucide-react' + +import { isRenderableCover } from '../utils/coverUrl' + +// 阅读子系统统一封面图。 +// +// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封), +// 直接塞进 会显示破图。这里统一处理:地址不是图片、或加载失败时, +// 一律回退成占位图标。 +export default function ReaderBookCover({ + url, + alt = '', + iconSize = 18, + className = 'h-full w-full object-cover', +}: { + url?: string | null + alt?: string + iconSize?: number + className?: string +}) { + const [failed, setFailed] = useState(false) + + useEffect(() => { + setFailed(false) + }, [url]) + + if (!isRenderableCover(url) || failed) { + return ( +
+ +
+ ) + } + + return ( + {alt} setFailed(true)} + /> + ) +} diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx index c842d46..73889b2 100644 --- a/web/src/pages/reader/ReaderBookPage.tsx +++ b/web/src/pages/reader/ReaderBookPage.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import toast from 'react-hot-toast' -import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react' +import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react' -import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader' +import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader' +import ReaderBookCover from '../../components/ReaderBookCover' +import { SourcePickerDialog } from './SourcePickerDialog' // 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。 @@ -24,6 +26,10 @@ export default function ReaderBookPage() { const [busy, setBusy] = useState(false) const [tocExpanded, setTocExpanded] = useState(false) const [introExpanded, setIntroExpanded] = useState(false) + // 换源:候选源来自按书名重新搜索的结果 + const [pickerOpen, setPickerOpen] = useState(false) + const [pickLoading, setPickLoading] = useState(false) + const [candidates, setCandidates] = useState([]) const loadInfo = () => { if (!bookURL) return @@ -120,6 +126,66 @@ export default function ReaderBookPage() { } } + // ── 换源 ── + + // 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。 + const openSourcePicker = async () => { + const key = (info?.name || qName).trim() + if (!key) { + toast.error('缺少书名,无法换源') + return + } + setPickerOpen(true) + setPickLoading(true) + setCandidates([]) + try { + const res = await readerAPI.search(key) + const list = res.books ?? [] + const wantAuthor = (info?.author || qAuthor).trim() + const hit = + list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ?? + list.find((b) => b.name === key) ?? + null + setCandidates(hit?.origins ?? []) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败') + } finally { + setPickLoading(false) + } + } + + const bookPath = (origin: ReaderSearchOrigin) => + `/reader/book?${new URLSearchParams({ + source_url: origin.origin, + book_url: origin.book_url, + name, + author, + cover_url: cover, + origin_id: origin.source_id, + origin_name: origin.origin_name, + }).toString()}` + + // 已在书架:调用换源接口(服务端保留进度、清空旧源目录缓存); + // 未在书架:这本书还没绑定书源,直接按所选源打开详情页即可。 + const applyOrigin = async (origin: ReaderSearchOrigin) => { + setPickerOpen(false) + if (!shelfBook) { + navigate(bookPath(origin)) + return + } + setBusy(true) + try { + await readerAPI.switchOrigin(shelfBook.id, origin) + toast.success(`已切换到「${origin.origin_name || origin.origin}」`) + // URL 上的旧源地址已失效,替换成新源地址,页面据此重新拉详情与目录 + navigate(bookPath(origin), { replace: true }) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败') + } finally { + setBusy(false) + } + } + if (!bookURL) { return (
缺少书籍参数
@@ -151,13 +217,7 @@ export default function ReaderBookPage() { {/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
- {cover ? ( - {name} - ) : ( -
- -
- )} +

{name || '未知书名'}

@@ -209,6 +269,15 @@ export default function ReaderBookPage() { {busy ? : null} {shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'} +
{/* 目录 */} @@ -245,6 +314,18 @@ export default function ReaderBookPage() {
)}
+ + {pickerOpen && ( + setPickerOpen(false)} + /> + )}
) } diff --git a/web/src/pages/reader/ReaderHomeContent.tsx b/web/src/pages/reader/ReaderHomeContent.tsx index 3923efa..ed23f32 100644 --- a/web/src/pages/reader/ReaderHomeContent.tsx +++ b/web/src/pages/reader/ReaderHomeContent.tsx @@ -5,6 +5,7 @@ import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loade import { readerAPI, type ReaderBook } from '../../api/reader' import { confirmAction } from '../../components/confirmAction' +import ReaderBookCover from '../../components/ReaderBookCover' import { useAuthStore } from '../../stores/auth' import { ReaderModeSwitch } from './ReaderModeSwitch' import { ServerFilePickerDialog } from './ServerFilePickerDialog' @@ -272,19 +273,7 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500" >
- {book.cover_url ? ( - {book.name} - ) : ( -
- -
- )} +
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */} {book.is_local && ( diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx index c8969ad..3685e81 100644 --- a/web/src/pages/reader/ReaderSearchPage.tsx +++ b/web/src/pages/reader/ReaderSearchPage.tsx @@ -1,9 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import toast from 'react-hot-toast' -import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react' +import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react' import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader' +import ReaderBookCover from '../../components/ReaderBookCover' +import { splitKindTags } from '../../utils/kindTags' +import { SourcePickerDialog } from './SourcePickerDialog' // 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。 @@ -12,6 +15,8 @@ export default function ReaderSearchPage() { const [params] = useSearchParams() const [key, setKey] = useState(() => params.get('key') ?? '') const [searching, setSearching] = useState(false) + // 换源:点「N 源可换」后选择用哪个源打开这本书 + const [picker, setPicker] = useState(null) const [books, setBooks] = useState(null) const [skipped, setSkipped] = useState([]) const [showSkipped, setShowSkipped] = useState(false) @@ -61,8 +66,7 @@ export default function ReaderSearchPage() { } } - const openBook = async (book: ReaderSearchBook) => { - const origin = book.origins[0] + const openBook = (book: ReaderSearchBook, origin = book.origins[0]) => { if (!origin) return navigate( `/reader/book?${new URLSearchParams({ @@ -125,36 +129,37 @@ export default function ReaderSearchPage() { >
- -

- {book.author || '佚名'} - {book.kind ? ` · ${book.kind}` : ''} - {book.word_count ? ` · ${book.word_count}` : ''} +

+ + {book.origins.length > 1 && ( + + )} +
+

+ {book.author || '佚名'} + {splitKindTags(book.kind).map((k) => ( + + {k} + + ))} + {book.word_count && ( + + {book.word_count} + + )}

最新:{book.latest_chapter || '未知'}

{book.intro &&

{book.intro}

} @@ -207,6 +212,19 @@ export default function ReaderSearchPage() { 输入关键词开始搜索 · 需要 先导入书源
)} + + {picker && ( + { + const book = picker + setPicker(null) + openBook(book, origin) + }} + onClose={() => setPicker(null)} + /> + )} ) } diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index ab077b0..796a5c4 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom' import toast from 'react-hot-toast' import { ArrowLeft, + ArrowLeftRight, BookOpen, LayoutList, ListEnd, @@ -15,10 +16,11 @@ import { } from 'lucide-react' import { Virtuoso } from 'react-virtuoso' -import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader' +import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader' import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings' import { ReaderAudioPanel } from './ReaderAudioPanel' import { ReaderComic } from './ReaderComic' +import { SourcePickerDialog } from './SourcePickerDialog' // 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、 // 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。 @@ -64,6 +66,11 @@ export default function ReaderViewPage() { const [menuOpen, setMenuOpen] = useState(false) const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none') + // 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载 + const [switchOpen, setSwitchOpen] = useState(false) + const [switchLoading, setSwitchLoading] = useState(false) + const [switchCandidates, setSwitchCandidates] = useState([]) + const [reloadKey, setReloadKey] = useState(0) // 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行 const topBarRef = useRef(null) const [menuInset, setMenuInset] = useState(0) @@ -139,7 +146,7 @@ export default function ReaderViewPage() { cancelled = true contentCache.current.clear() } - }, [bookId]) + }, [bookId, reloadKey]) // ── 加载章节正文(带缓存与下一章预取) ── useEffect(() => { @@ -324,6 +331,54 @@ export default function ReaderViewPage() { return false }, [chapterIndex, chapters]) + // ── 换源(对应 legado 阅读页的「换源」) ── + // + // 候选源来自按书名重新搜索;选中后调用换源接口,服务端保留阅读进度、清空旧源 + // 目录缓存,这里只需要清掉正文缓存并让整本书重新加载。 + const openSourcePicker = useCallback(async () => { + const key = (book?.name ?? '').trim() + if (!key) { + toast.error('缺少书名,无法换源') + return + } + setSwitchOpen(true) + setSwitchLoading(true) + setSwitchCandidates([]) + try { + const res = await readerAPI.search(key) + const list = res.books ?? [] + const wantAuthor = (book?.author ?? '').trim() + const hit = + list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ?? + list.find((b) => b.name === key) ?? + null + setSwitchCandidates(hit?.origins ?? []) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败') + } finally { + setSwitchLoading(false) + } + }, [book]) + + const applyOrigin = useCallback( + async (origin: ReaderSearchOrigin) => { + setSwitchOpen(false) + if (!book) return + try { + await readerAPI.switchOrigin(book.id, origin) + toast.success(`已切换到「${origin.origin_name || origin.origin}」`) + contentCache.current.clear() + setReloadKey((v) => v + 1) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败') + } + }, + [book], + ) + + // 本地导入的书没有书源,不显示换源入口 + const canSwitchSource = !!book && !book.is_local + // 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits) const saveAudioCredits = useCallback( (open: number, close: number) => { @@ -739,8 +794,8 @@ export default function ReaderViewPage() { - {/* 动作行(目录 / 界面 / 夜间 / 模式) */} -
+ {/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */} +
{([ { icon: , label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') }, { icon: , label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') }, @@ -754,6 +809,17 @@ export default function ReaderViewPage() { label: settings.pageMode === 'page' ? '滚动' : '翻页', action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'), }, + ...(canSwitchSource + ? [ + { + icon: , + label: '换源', + action: () => { + void openSourcePicker() + }, + }, + ] + : []), ]).map((item) => (
) } diff --git a/web/src/pages/reader/SourcePickerDialog.tsx b/web/src/pages/reader/SourcePickerDialog.tsx new file mode 100644 index 0000000..e9a690c --- /dev/null +++ b/web/src/pages/reader/SourcePickerDialog.tsx @@ -0,0 +1,105 @@ +import { BookOpen, Check, Loader2, X } from 'lucide-react' + +import type { ReaderSearchOrigin } from '../../api/reader' + +/** + * SourcePickerDialog 换源弹窗:列出同一本书可用的书源,选中即切换。 + * + * 书源对同一本书的封面 / 最新章节等覆盖能力不同,所以列表里带上各源的最新章节, + * 方便挑一个更全的源;当前正在使用的源会标注「当前」且不可重复选择。 + */ +export function SourcePickerDialog({ + title, + origins, + current, + loading = false, + emptyHint = '没有找到其它书源', + onPick, + onClose, +}: { + title: string + origins: ReaderSearchOrigin[] + /** 当前正在使用的源:优先按书本地址匹配(同源可能承载多本书),其次按书源地址 */ + current?: { originURL?: string; bookURL?: string } + loading?: boolean + emptyHint?: string + onPick: (origin: ReaderSearchOrigin) => void + onClose: () => void +}) { + return ( +
+
event.stopPropagation()} + > +
+
+

换源 · {title}

+

+ {loading ? '正在搜索其它书源…' : `共 ${origins.length} 个可用书源 · 阅读进度会保留`} +

+
+ +
+ +
+ {loading ? ( +
+ +
+ ) : origins.length === 0 ? ( +

{emptyHint}

+ ) : ( +
+ {origins.map((origin) => { + const isCurrent = + (!!current?.bookURL && origin.book_url === current.bookURL) || + (!current?.bookURL && !!current?.originURL && origin.origin === current.originURL) + return ( + + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/web/src/utils/coverUrl.test.ts b/web/src/utils/coverUrl.test.ts new file mode 100644 index 0000000..f483155 --- /dev/null +++ b/web/src/utils/coverUrl.test.ts @@ -0,0 +1,30 @@ +import { isRenderableCover } from './coverUrl.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`coverUrl: ${name}`) +} + +check('empty is not renderable', !isRenderableCover('')) +check('null is not renderable', !isRenderableCover(null)) +check('undefined is not renderable', !isRenderableCover(undefined)) +check('blank string is not renderable', !isRenderableCover(' ')) +check('https cover is renderable', isRenderableCover('https://img.example.com/a.jpg')) +check('http cover is renderable', isRenderableCover('http://img.example.com/a.jpg')) +check('leading spaces are tolerated', isRenderableCover(' https://img.example.com/a.jpg ')) +check('uppercase scheme is renderable', isRenderableCover('HTTPS://img.example.com/a.jpg')) +check('inline image data uri is renderable', isRenderableCover('data:image/png;base64,iVBORw0KGgo=')) +check('local asset path is renderable', isRenderableCover('/api/reader/local/asset?b=1&p=2&s=3')) +check('protocol relative cover is renderable', isRenderableCover('//img.example.com/a.jpg')) +// 聚合书源把搜索参数信封当封面返回的真实取值 +check( + 'search envelope data uri is not renderable', + !isRenderableCover( + 'data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==', + ), +) +check('text data uri is not renderable', !isRenderableCover('data:text/plain;base64,aGk=')) +check('source name is not renderable', !isRenderableCover('光遇聚合')) +check('bare relative path is not renderable', !isRenderableCover('files/a.jpg')) +check('javascript url is not renderable', !isRenderableCover('javascript:alert(1)')) + +console.log('coverUrl.test.ts ok') diff --git a/web/src/utils/coverUrl.ts b/web/src/utils/coverUrl.ts new file mode 100644 index 0000000..bedfadc --- /dev/null +++ b/web/src/utils/coverUrl.ts @@ -0,0 +1,20 @@ +// 判断一个封面地址能否真正被浏览器渲染成图片。 +// +// 书源在「这本书没有封面」时不一定返回空值:聚合类书源(如「光遇聚合」)的 +// 搜索请求地址本身是 data:;base64,... 参数信封,规则取值为空时会把它当地址 +// 兜底返回,于是 cover_url 变成一串 data: 文本。这类地址 media type 为空, +// 浏览器按 text/plain 解码,必然显示破图。 +export function isRenderableCover(url?: string | null): boolean { + const value = (url ?? '').trim() + if (!value) return false + + const lower = value.toLowerCase() + if (lower.startsWith('http://') || lower.startsWith('https://')) return true + if (lower.startsWith('data:image/')) return true + // 应用自身生成的相对地址(本地书封面 /api/reader/local/asset?...)以及 + // //host/path 这种协议相对地址。书源给出的相对地址同样落在这里,加载失败 + // 时由 的 onError 兜底回退成占位图。 + if (value.startsWith('/')) return true + + return false +} diff --git a/web/src/utils/kindTags.test.ts b/web/src/utils/kindTags.test.ts new file mode 100644 index 0000000..f64f1b0 --- /dev/null +++ b/web/src/utils/kindTags.test.ts @@ -0,0 +1,20 @@ +import { splitKindTags } from './kindTags.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`kindTags: ${name}`) +} + +const eq = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]) + +check('空值返回空数组', splitKindTags(undefined).length === 0) +check('空串返回空数组', splitKindTags('').length === 0) +check('全空段返回空数组', splitKindTags(',,,').length === 0) +// 聚合源的真实取值:只有状态/分类有值,其余为空 +check('丢掉空段', eq(splitKindTags('已完结,,,'), ['已完结'])) +check('保留有效段顺序', eq(splitKindTags('已完结,玄幻,'), ['已完结', '玄幻'])) +check('去掉段内空白', eq(splitKindTags(' 连载 , 都市 '), ['连载', '都市'])) +check('无逗号时整体作一段', eq(splitKindTags('有声小说'), ['有声小说'])) +// 更新时间段也是合法标签(legado 同样展示) +check('保留更新时间段', eq(splitKindTags(',,有声小说,2018-12-11 22:01:22'), ['有声小说', '2018-12-11 22:01:22'])) + +console.log('kindTags.test.ts ok') diff --git a/web/src/utils/kindTags.ts b/web/src/utils/kindTags.ts new file mode 100644 index 0000000..f0b047c --- /dev/null +++ b/web/src/utils/kindTags.ts @@ -0,0 +1,11 @@ +// 书源的 kind 规则普遍是逗号拼接的多段,例如聚合源的 +// `{{$.status}},{{$.score}},{{$.tags}},{{$.last_chapter_update_time}}`。 +// +// 取不到值的段会是空串(如 "已完结,,,"),直接展示会变成 ",,有声小说," 这种噪音, +// 所以按逗号切分后丢掉空段,只保留有效标签(legado 也是按段展示)。 +export function splitKindTags(kind?: string | null): string[] { + return (kind ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +}