mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
优化
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回,
|
||||
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: 文本,<img> 按 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)
|
||||
|
||||
@@ -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,... 参数信封,直接回退会把信封当成封面/书址
|
||||
// 返回,前端 <img> 只能显示破图。故仅在 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。
|
||||
// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址,
|
||||
// 回退结果会把封面/书址污染成非图片文本(前端 <img> 直接破图)。
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -283,6 +283,12 @@ export const readerAPI = {
|
||||
.post<ReaderBook>('/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<ReaderBook>(`/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 不跳过)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<BookOpen size={14} />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={book.cover_url} iconSize={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
import { isRenderableCover } from '../utils/coverUrl'
|
||||
|
||||
// 阅读子系统统一封面图。
|
||||
//
|
||||
// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
|
||||
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
|
||||
// 一律回退成占位图标。
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url ?? ''}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<ReaderSearchOrigin[]>([])
|
||||
|
||||
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 (
|
||||
<div className="mx-auto px-6 py-24 text-center text-sm text-[var(--app-muted)]">缺少书籍参数</div>
|
||||
@@ -151,13 +217,7 @@ export default function ReaderBookPage() {
|
||||
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
|
||||
<div className="mt-6 flex gap-5">
|
||||
<div className="h-40 w-28 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
{cover ? (
|
||||
<img src={cover} alt={name} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={24} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={cover} alt={name} iconSize={24} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="font-display text-xl text-ink-600">{name || '未知书名'}</h1>
|
||||
@@ -209,6 +269,15 @@ export default function ReaderBookPage() {
|
||||
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
|
||||
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSourcePicker}
|
||||
disabled={busy}
|
||||
className="btn-outline shrink-0 px-3 text-xs disabled:opacity-50"
|
||||
title="换源"
|
||||
>
|
||||
<ArrowLeftRight size={13} className="mr-1 inline" /> 换源
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 目录 */}
|
||||
@@ -245,6 +314,18 @@ export default function ReaderBookPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pickerOpen && (
|
||||
<SourcePickerDialog
|
||||
title={name || '这本书'}
|
||||
origins={candidates}
|
||||
current={{ originURL: sourceURL, bookURL }}
|
||||
loading={pickLoading}
|
||||
emptyHint="按书名重搜后没有找到其它书源"
|
||||
onPick={applyOrigin}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={22} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
|
||||
{book.is_local && (
|
||||
|
||||
@@ -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<ReaderSearchBook | null>(null)
|
||||
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
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() {
|
||||
>
|
||||
<button type="button" onClick={() => openBook(book)} className="shrink-0">
|
||||
<div className="h-24 w-16 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={18} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={18} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button type="button" onClick={() => openBook(book)} className="text-left">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">
|
||||
{book.name}
|
||||
{book.origins.length > 1 && (
|
||||
<span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||||
{book.origins.length} 源可换
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
|
||||
{book.author || '佚名'}
|
||||
{book.kind ? ` · ${book.kind}` : ''}
|
||||
{book.word_count ? ` · ${book.word_count}` : ''}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => openBook(book)} className="min-w-0 text-left">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">{book.name}</p>
|
||||
</button>
|
||||
{book.origins.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicker(book)}
|
||||
title="换源"
|
||||
className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600 transition hover:bg-brand-500/20"
|
||||
>
|
||||
{book.origins.length} 源可换
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-[var(--app-muted)]">
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{splitKindTags(book.kind).map((k) => (
|
||||
<span key={k} className="shrink-0 rounded bg-brand-500/10 px-1 py-0.5 text-2xs font-bold text-brand-600">
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
{book.word_count && (
|
||||
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||
{book.word_count}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">最新:{book.latest_chapter || '未知'}</p>
|
||||
{book.intro && <p className="mt-1 line-clamp-2 text-xs text-[var(--app-subtle)]">{book.intro}</p>}
|
||||
@@ -207,6 +212,19 @@ export default function ReaderSearchPage() {
|
||||
输入关键词开始搜索 · 需要 <Link to="/reader/sources" className="text-brand-600 hover:underline">先导入书源</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<SourcePickerDialog
|
||||
title={picker.name}
|
||||
origins={picker.origins}
|
||||
onPick={(origin) => {
|
||||
const book = picker
|
||||
setPicker(null)
|
||||
openBook(book, origin)
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ReaderSearchOrigin[]>([])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
const topBarRef = useRef<HTMLDivElement>(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() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式) */}
|
||||
<div className="grid grid-cols-4 pt-1" style={{ color: theme.text }}>
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
|
||||
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
|
||||
{ icon: <BookOpen size={18} />, 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: <ArrowLeftRight size={18} />,
|
||||
label: '换源',
|
||||
action: () => {
|
||||
void openSourcePicker()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]).map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
@@ -879,6 +945,19 @@ export default function ReaderViewPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{switchOpen && (
|
||||
<SourcePickerDialog
|
||||
title={book?.name ?? '这本书'}
|
||||
origins={switchCandidates}
|
||||
current={{ originURL: book?.origin, bookURL: book?.book_url }}
|
||||
loading={switchLoading}
|
||||
emptyHint="按书名重搜后没有找到其它书源"
|
||||
onPick={(origin) => {
|
||||
void applyOrigin(origin)
|
||||
}}
|
||||
onClose={() => setSwitchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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="truncate text-sm font-bold text-[var(--app-text)]">换源 · {title}</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
{loading ? '正在搜索其它书源…' : `共 ${origins.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="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
</div>
|
||||
) : origins.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">{emptyHint}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{origins.map((origin) => {
|
||||
const isCurrent =
|
||||
(!!current?.bookURL && origin.book_url === current.bookURL) ||
|
||||
(!current?.bookURL && !!current?.originURL && origin.origin === current.originURL)
|
||||
return (
|
||||
<button
|
||||
key={`${origin.source_id}|${origin.origin}|${origin.book_url}`}
|
||||
type="button"
|
||||
disabled={isCurrent}
|
||||
onClick={() => onPick(origin)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-60"
|
||||
>
|
||||
<BookOpen size={15} className="shrink-0 text-brand-500" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{origin.origin_name || origin.origin}
|
||||
</span>
|
||||
<span className="block truncate text-2xs text-[var(--app-muted)]">
|
||||
{origin.latest_chapter
|
||||
? `最新:${origin.latest_chapter}`
|
||||
: `书源:${origin.origin || origin.source_id}`}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-2xs font-bold text-brand-600">
|
||||
<Check size={12} /> 当前
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-2xs text-brand-600">切换</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 这种协议相对地址。书源给出的相对地址同样落在这里,加载失败
|
||||
// 时由 <img> 的 onError 兜底回退成占位图。
|
||||
if (value.startsWith('/')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user