Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile b8b02cb3a7 优化 2026-10-03 14:21:07 +08:00
truewhile 60cfad47e5 优化 2026-10-03 14:05:24 +08:00
truewhile 47c8bfdd6c bug处理 2026-10-03 13:38:18 +08:00
10 changed files with 419 additions and 43 deletions
+2 -2
View File
@@ -33,13 +33,13 @@
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
**阅读器偏好按账号同步(跨设备)**
@@ -0,0 +1,149 @@
package reader
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 本文件:书籍类型兜底的回归测试。
//
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
// 整个阅读页是一屏 URL(用户截图里的现象)。
//
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
func audioTestSourceJSON(t *testing.T, base string) string {
t.Helper()
return `{
"bookSourceName": "听书测试源",
"bookSourceType": 1,
"bookSourceUrl": "` + base + `",
"ruleContent": {"content": "$.data.url"}
}`
}
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
// 并把纠正后的类型写回书架记录。
func TestAudioBookTypeFallbackBySource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
// 复刻详情页写死的 origin_type=0
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID,
Origin: srv.URL,
OriginName: "听书测试源",
OriginType: 0,
BookURL: srv.URL + "/book/1",
}, "测试听书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "audio" {
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
out.Type, out.Content)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
}
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
if err != nil {
t.Fatalf("代理地址校验失败: %v", err)
}
if raw != "https://cdn.example.com/a.m4a" {
t.Fatalf("代理还原地址 = %q", raw)
}
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
// book.type 也一直是错的(书源 JS 会读它分支)。
reloaded, err := svc.GetBook(ctx, book.ID)
if err != nil {
t.Fatalf("重读书籍失败: %v", err)
}
if reloaded.Type != 1 {
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
}
}
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
src := `{
"bookSourceName": "声明文本的音频源",
"bookSourceType": 1,
"bookSourceUrl": "` + srv.URL + `",
"ruleContent": {"content": "@js:book.type = 8; result"}
}`
sourceID := prepareLoginSource(t, svc, src)
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
OriginType: 0, BookURL: srv.URL + "/book/1",
}, "声明文本", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
if out.Type != "text" {
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
}
}
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
func trackQueryValue(t *testing.T, raw, key string) string {
t.Helper()
idx := strings.Index(raw, key+"=")
if idx < 0 {
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
}
rest := raw[idx+len(key)+1:]
if end := strings.Index(rest, "&"); end >= 0 {
rest = rest[:end]
}
return rest
}
+94 -25
View File
@@ -1139,6 +1139,21 @@ func normalizeBookType(t int) int {
return 0
}
// contentTypeCode 把 ChapterContent.Type 换算回书架记录的类型码
// (0 文本 / 1 音频 / 2 图片,与 model.ReaderBook.Type 同一套约定)。
// 第二个返回值为 false 表示这个渲染类型没有对应的书籍类型(如视频)。
func contentTypeCode(t string) (int, bool) {
switch t {
case "text":
return 0, true
case "audio":
return 1, true
case "image":
return 2, true
}
return 0, false
}
// GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型
// (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。
func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) {
@@ -1655,7 +1670,14 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
if declaredType >= 0 {
effective = declaredType
}
if effective < 0 {
// 书架类型还停在默认的「文本」时,用书源声明的类型兜底。
//
// 书籍详情页的「加入书架/开始阅读」曾经把 origin_type 写死成 0(见 web
// ReaderBookPage),听书/漫画书于是以文本类型落库;而书架类型优先级高于
// 书源类型,正文就被当文本渲染——听书源的播放直链会排满整个阅读页。
// 规则 JS 里显式声明的类型(declaredType)仍然最优先,所以「文本型聚合源
// 提供听书/漫画内容」这类靠 JS 声明类型的情形不受影响。
if declaredType < 0 && effective <= 0 {
effective = src.Type
}
switch effective {
@@ -2052,11 +2074,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b
// TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。
type TocRefreshResult struct {
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Failed int `json:"failed"` // 抓取或写入失败的书
Total int `json:"total"` // 参与刷新的网络书籍数
Updated int `json:"updated"` // 章数变多的书(有新章节)
Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新)
Failed int `json:"failed"` // 抓取或写入失败的书
}
// tocRefreshStatus 单本书目录刷新的结果。
type tocRefreshStatus int
const (
// tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。
tocRefreshUnchanged tocRefreshStatus = iota
// tocRefreshUpdated 抓取成功且章数变多,有新章节。
tocRefreshUpdated
// tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。
tocRefreshFailed
)
// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。
const refreshTocConcurrency = 4
@@ -2065,6 +2100,8 @@ const refreshTocConcurrency = 4
// 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新
// latest_chapter_time。本地书籍与没有书源信息的书籍跳过。
// 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。
// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节),
// 混在一起会让人误以为书源坏了。
func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) {
books, err := s.repo.ListBooks(ctx, userID)
if err != nil {
@@ -2096,43 +2133,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To
defer func() { <-sem }()
bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout)
defer cancel()
if s.refreshBookToc(bookCtx, userID, b) {
mu.Lock()
res.Updated++
mu.Unlock()
return
}
status := s.refreshBookToc(bookCtx, userID, b)
mu.Lock()
res.Failed++
mu.Unlock()
defer mu.Unlock()
switch status {
case tocRefreshUpdated:
res.Updated++
case tocRefreshUnchanged:
res.Unchanged++
default:
res.Failed++
}
}(b)
}
wg.Wait()
return res, nil
}
// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool {
// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。
func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus {
before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID})
chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL)
if err != nil || len(chapters) == 0 {
if err != nil && s.log != nil {
s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err))
}
return false
if err != nil {
s.logTocRefreshFailure("reader: 更新目录失败", book, err)
return tocRefreshFailed
}
// 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。
// 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。
if len(chapters) == 0 {
s.logTocRefreshFailure("reader: 更新目录为空", book, nil)
return tocRefreshFailed
}
inputs := make([]ChapterInput, 0, len(chapters))
for _, ch := range chapters {
inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume})
}
if err := s.SaveChapters(ctx, book.ID, inputs); err != nil {
if s.log != nil {
s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err))
}
return false
s.logTocRefreshFailure("reader: 更新目录写入失败", book, err)
return tocRefreshFailed
}
beforeCount := before[book.ID]
return beforeCount > 0 && len(chapters) > beforeCount
// 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。
if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount {
return tocRefreshUpdated
}
return tocRefreshUnchanged
}
// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自
// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。
func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) {
if s.log == nil {
return
}
fields := []zap.Field{
zap.String("book", book.ID),
zap.String("name", book.Name),
zap.String("origin", book.OriginName),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.log.Warn(msg, fields...)
}
// ListReplaceRules 用户替换规则列表。
@@ -2257,6 +2318,14 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err))
}
} else if code, ok := contentTypeCode(out.Type); ok && code != 0 && code != book.Type {
// 书源类型兜底纠正出来的音频/漫画类型也落库:这些书被详情页写死的
// origin_type=0 记成了文本,不写回的话每读一章都要再纠正一次,
// 注入规则 JS 的 book.type 也一直是错的。只向上纠正,不覆盖成文本。
book.Type = code
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 修正书籍类型失败", zap.String("book", book.ID), zap.Error(err))
}
}
if out.Type == "text" {
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
+134
View File
@@ -0,0 +1,134 @@
package reader
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件:「更新目录」的结果分档回归测试。
//
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
// refreshTocForTest 跑一次「更新目录」并返回汇总。
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
t.Helper()
res, err := svc.RefreshBooksToc(ctx, "u1")
if err != nil {
t.Fatalf("更新目录失败: %v", err)
}
return res
}
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
(*tocHandler)(w, r)
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(e2eBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
}
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(e2eTocHTML))
})
srv := newRefreshTocServer(&tocHandler)
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book := &model.ReaderBook{
UserID: "u1",
Origin: srv.URL,
OriginName: "测试源",
BookURL: srv.URL + "/book/1",
TocURL: srv.URL + "/book/1/toc.html",
Name: "斗破苍穹",
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
res := refreshTocForTest(t, svc, ctx)
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
}
chapters, err := svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 2 {
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
}
// 2) 站点多出一章:这才算「有新章节」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(strings.Replace(
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
)))
}
res = refreshTocForTest(t, svc, ctx)
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
}
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
// 这种静默失败必须记失败,不能报成「已是最新」。
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
}
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
}
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
chapters, err = svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(chapters) != 3 {
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
}
// 4) 站点 404:抓取失败。
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
res = refreshTocForTest(t, svc, ctx)
if res.Failed != 1 || res.Unchanged != 0 {
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
}
}
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
for _, b := range []*model.ReaderBook{local, sourceless} {
if err := svc.repo.CreateBook(ctx, b); err != nil {
t.Fatalf("创建书籍失败: %v", err)
}
}
res := refreshTocForTest(t, svc, ctx)
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
}
}
+7 -2
View File
@@ -317,11 +317,16 @@ export const readerAPI = {
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
/**
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
*/
refreshBooksToc: () =>
api
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
'/reader/shelf/refresh-toc',
{},
longOpts,
)
.then((r) => r.data),
/**
* 换源:把书架里的书切到另一个书源。
+1 -1
View File
@@ -4,7 +4,7 @@ import type { BookGroupTab } from '../utils/readerBookGroups'
/**
* 书架分组栏(对齐影视模块的 LibraryTagBar):
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
* 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。
*
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
+13 -1
View File
@@ -24,6 +24,8 @@ export default function ReaderBookPage() {
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
const [busy, setBusy] = useState(false)
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
const [sourceType, setSourceType] = useState(0)
const [tocExpanded, setTocExpanded] = useState(false)
const [introExpanded, setIntroExpanded] = useState(false)
// 换源:候选源来自按书名重新搜索的结果
@@ -58,6 +60,16 @@ export default function ReaderBookPage() {
.catch(() => setChapters([]))
}, [bookURL, sourceURL])
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
useEffect(() => {
if (!sourceURL) return
readerAPI
.listSources()
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
.catch(() => undefined)
}, [sourceURL])
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
useEffect(() => {
const tocURL = info?.toc_url
@@ -84,7 +96,7 @@ export default function ReaderBookPage() {
source_id: originID,
origin: sourceURL,
origin_name: originName || info?.name || '',
origin_type: 0,
origin_type: sourceType,
book_url: bookURL,
latest_chapter: info?.latest_chapter ?? '',
},
+5 -1
View File
@@ -250,7 +250,11 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
if (res.total === 0) {
toast.success('没有需要更新的网络书籍', { id: toastId })
} else if (res.failed > 0) {
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
// 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档
const parts = [`${res.updated} 本有新章节`]
if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`)
parts.push(`${res.failed} 本失败,可在日志中查看书源报错`)
toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' })
} else {
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
}
+5 -4
View File
@@ -66,13 +66,14 @@ const noGroups = buildBookGroupTabs(books, [])
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
const tabs = buildBookGroupTabs(books, groups)
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
check('tabs = 全部 + groups + 未分组(未分组殿后)', tabs.map((t) => t.name).join(',') === '全部,科幻,在读,未分组')
check('全部 counts all books', tabs[0].count === 4)
check('未分组 counts unclaimed', tabs[1].count === 1)
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
check('ungrouped is the last tab', tabs[tabs.length - 1].kind === 'ungrouped')
check('未分组 counts unclaimed', tabs[3].count === 1)
check('group counts only existing books', tabs[1].count === 2 && tabs[2].count === 1)
check('stale ids do not inflate counts', (() => {
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
return withStale[2].count === 1 && withStale[1].count === 3
return withStale[1].count === 1 && withStale[2].count === 3
})())
// ── 排序与选中回落 ──
+9 -7
View File
@@ -204,7 +204,9 @@ export function filterBooksByGroup<T extends { id: string }>(
}
/**
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
* 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。
* 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会
* 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
@@ -219,12 +221,6 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
]
if (groups.length === 0) return tabs
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
for (const group of groups) {
tabs.push({
id: group.name,
@@ -233,6 +229,12 @@ export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[])
kind: 'group',
})
}
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
return tabs
}