diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index 8d515ae..e3a0564 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -26,6 +26,29 @@ - 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。 - `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。 +**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)** +- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`: + - 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。 + - 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。 + - 紧凑列表: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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。 + +**书架分组(`/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`(移到分组)。 + +**阅读器偏好按账号同步(跨设备)** +- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。 +- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。 +- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。 +- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。 +- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。 + ## 2. 阅读界面(重点) 文件:`ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。 @@ -91,7 +114,7 @@ - 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。 - 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。 - 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。** -- 播放进度按秒记忆(节流 10s 上报),跨端一致;播完自动下一章。 +- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。 - legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。 ## 3. 搜索 diff --git a/internal/handler/reader_groups_test.go b/internal/handler/reader_groups_test.go new file mode 100644 index 0000000..6032261 --- /dev/null +++ b/internal/handler/reader_groups_test.go @@ -0,0 +1,240 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" + "github.com/truewhile/MeBox/internal/service/reader" +) + +// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。 +// +// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。 +// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。 + +func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook { + t.Helper() + book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{ + Origin: "https://example.com", + BookURL: bookURL, + }, name, "作者", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + return book +} + +func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) { + t.Helper() + body, _ := json.Marshal(map[string]any{"groups": groups}) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", user) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + return nil, w.Code + } + var res struct { + Groups []model.BookGroupSet `json:"groups"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String()) + } + return res.Groups, w.Code +} + +func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil) + req.Header.Set("X-Test-User", user) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + return nil, w.Code + } + var res struct { + Groups []model.BookGroupSet `json:"groups"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String()) + } + return res.Groups, w.Code +} + +func namesOf(groups []model.BookGroupSet) string { + out := make([]string, 0, len(groups)) + for _, g := range groups { + out = append(out, g.Name) + } + return strings.Join(out, ",") +} + +func idsOf(groups []model.BookGroupSet, name string) string { + for _, g := range groups { + if g.Name == name { + return strings.Join(g.BookIDs, ",") + } + } + return "" +} + +// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。 +func TestBookGroupsEmptyStateIsArray(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + // 必须是 [] 而不是 null + if !strings.Contains(w.Body.String(), `"groups":[]`) { + t.Fatalf("空态应返回 [],实际 %s", w.Body.String()) + } +} + +func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1") + b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2") + b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3") + foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9") + + got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{ + {Name: "科幻", BookIDs: []string{b1.ID, b2.ID}}, + {Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空 + {Name: "玄幻", BookIDs: []string{b3.ID}}, + {Name: "空组", BookIDs: []string{}}, + {Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组 + {Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉 + }) + if code != http.StatusOK { + t.Fatalf("保存失败 status=%d", code) + } + if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" { + t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names) + } + if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID { + t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID) + } + if ids := idsOf(got, "在读"); ids != "" { + t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids) + } + if ids := idsOf(got, "玄幻"); ids != b3.ID { + t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID) + } + if ids := idsOf(got, "外来"); ids != "" { + t.Fatalf("外来 应被过滤为空,实际 %q", ids) + } + + // 读回来应当一致 + loaded, code := getBookGroups(t, router, "u1") + if code != http.StatusOK { + t.Fatalf("读回 status=%d", code) + } + if names := namesOf(loaded); names != namesOf(got) { + t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got)) + } + if ids := idsOf(loaded, "玄幻"); ids != b3.ID { + t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID) + } +} + +// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。 +func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1") + b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2") + + if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{ + {Name: "科幻", BookIDs: []string{b1.ID, b2.ID}}, + }); code != http.StatusOK { + t.Fatalf("保存失败 status=%d", code) + } + + if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil { + t.Fatalf("移出书架失败: %v", err) + } + + loaded, _ := getBookGroups(t, router, "u1") + if ids := idsOf(loaded, "科幻"); ids != b1.ID { + t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID) + } +} + +func TestBookGroupsArePerUser(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1") + u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2") + + if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{ + {Name: "u1 的分组", BookIDs: []string{u1Book.ID}}, + }); code != http.StatusOK { + t.Fatalf("u1 保存失败 status=%d", code) + } + + // u2 看不到 u1 的分组 + if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 { + t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code) + } + + // u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架) + if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{ + {Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}}, + }); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID { + t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code) + } + + // u1 的分组不受影响 + back, _ := getBookGroups(t, router, "u1") + if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID { + t.Fatalf("u1 分组被 u2 影响: %+v", back) + } +} + +// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。 +func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1") + + if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{ + {Name: "旧组", BookIDs: []string{b1.ID}}, + }); code != http.StatusOK { + t.Fatalf("首次保存 status=%d", code) + } + got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{ + {Name: "新组", BookIDs: []string{}}, + }) + if code != http.StatusOK { + t.Fatalf("二次保存 status=%d", code) + } + if names := namesOf(got); names != "新组" { + t.Fatalf("二次保存后 = %q,期望只有 新组", names) + } + + // 清空分组 + if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 { + t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code) + } + if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 { + t.Fatalf("清空后读回 = %+v,期望空", groups) + } +} diff --git a/internal/handler/reader_profile_test.go b/internal/handler/reader_profile_test.go new file mode 100644 index 0000000..3f2e1a0 --- /dev/null +++ b/internal/handler/reader_profile_test.go @@ -0,0 +1,248 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" + "github.com/truewhile/MeBox/internal/service/reader" +) + +// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。 +// +// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库, +// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。 + +// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1), +// 便于在一个测试里切换用户验证隔离。 +func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + user := c.GetHeader("X-Test-User") + if user == "" { + user = "u1" + } + c.Set(middleware.CtxUserID, user) + c.Next() + }) + registerReaderRoutes(r.Group("/api"), cfg) + return r +} + +func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil) + req.Header.Set("X-Test-User", user) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + return nil, w.Code + } + var res struct { + Profile *reader.ReaderSettings `json:"profile"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String()) + } + return res.Profile, w.Code +} + +func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-User", user) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + return nil, w.Code + } + var res struct { + Profile *reader.ReaderSettings `json:"profile"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String()) + } + return res.Profile, w.Code +} + +func TestReaderProfileNullBeforeFirstSave(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + profile, code := getProfile(t, router, "u1") + if code != http.StatusOK { + t.Fatalf("status=%d", code) + } + // 没有记录时返回 null,由前端用它本地的值播种 + if profile != nil { + t.Fatalf("新用户应返回 null,实际 %+v", profile) + } +} + +func TestReaderProfileRoundTrip(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + body := `{ + "theme_id":"preset2","night":true,"page_mode":"scroll", + "font_size":24,"line_height":2.1,"paragraph_spacing":12, + "audio_speed":1.5,"audio_timer_minutes":30, + "shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update", + "shelf_show_unread":false,"shelf_show_update_time":true + }` + saved, code := putProfile(t, router, "u1", body) + if code != http.StatusOK { + t.Fatalf("保存失败 status=%d", code) + } + if saved == nil { + t.Fatal("保存应返回偏好") + } + + got, code := getProfile(t, router, "u1") + if code != http.StatusOK || got == nil { + t.Fatalf("读回失败 status=%d profile=%+v", code, got) + } + if *got != *saved { + t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got) + } + if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 || + got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 || + got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 || + got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime { + t.Fatalf("字段未按原值往返: %+v", *got) + } +} + +func TestReaderProfileClampsAndFallsBack(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + // 全部越界 / 枚举非法,服务端应收敛而不是原样入库 + body := `{ + "theme_id":" ","night":false,"page_mode":"nope", + "font_size":999,"line_height":0.1,"paragraph_spacing":-5, + "audio_speed":99,"audio_timer_minutes":9999, + "shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random", + "shelf_show_unread":true,"shelf_show_update_time":false + }` + got, code := putProfile(t, router, "u1", body) + if code != http.StatusOK { + t.Fatalf("保存失败 status=%d", code) + } + if got.ThemeID != "preset1" { + t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID) + } + if got.PageMode != "page" { + t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode) + } + if got.FontSize != 32 { + t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize) + } + if got.LineHeight != 1.4 { + t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight) + } + if got.ParagraphSpacing != 0 { + t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing) + } + if got.AudioSpeed != 3 { + t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed) + } + if got.AudioTimerMinutes != 180 { + t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes) + } + if got.ShelfLayout != "grid" { + t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout) + } + if got.ShelfGridColumns != 6 { + t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns) + } + if got.ShelfSort != "recent" { + t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort) + } + // 布尔值不参与收敛,必须原样保留 + if !got.ShelfShowUnread || got.ShelfShowUpdateTime { + t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime) + } +} + +func TestReaderProfileIsPerUser(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + if _, code := putProfile(t, router, "u1", `{ + "theme_id":"preset3","night":true,"page_mode":"page","font_size":18, + "line_height":1.5,"paragraph_spacing":4,"audio_speed":1, + "audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0, + "shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true + }`); code != http.StatusOK { + t.Fatalf("u1 保存失败 status=%d", code) + } + + // 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录 + other, code := getProfile(t, router, "u2") + if code != http.StatusOK { + t.Fatalf("u2 读取 status=%d", code) + } + if other != nil { + t.Fatalf("u2 应返回 null,实际 %+v", other) + } + + if _, code := putProfile(t, router, "u2", `{ + "theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20, + "line_height":1.8,"paragraph_spacing":8,"audio_speed":2, + "audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3, + "shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false + }`); code != http.StatusOK { + t.Fatalf("u2 保存失败 status=%d", code) + } + + // u1 的值不能被 u2 覆盖 + back, code := getProfile(t, router, "u1") + if code != http.StatusOK || back == nil { + t.Fatalf("u1 读回失败 status=%d", code) + } + if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night { + t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back) + } +} + +func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) { + svc := newReaderHandlerContainer(t) + router := profileRouterForTest(t, svc) + + first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8, + "paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}` + if _, code := putProfile(t, router, "u1", first); code != http.StatusOK { + t.Fatalf("首次保存 status=%d", code) + } + second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0, + "paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}` + if _, code := putProfile(t, router, "u1", second); code != http.StatusOK { + t.Fatalf("二次保存 status=%d", code) + } + + got, _ := getProfile(t, router, "u1") + if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" { + t.Fatalf("二次保存未覆盖旧值: %+v", got) + } + + // 一个用户只应有一条记录(唯一索引 + upsert) + var count int64 + if err := svc.Repo.DB.Model(&model.ReaderProfile{}). + Where("user_id = ?", "u1").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("u1 的记录数 = %d,期望 1", count) + } +} diff --git a/internal/handler/reader_progress_test.go b/internal/handler/reader_progress_test.go new file mode 100644 index 0000000..3d25a31 --- /dev/null +++ b/internal/handler/reader_progress_test.go @@ -0,0 +1,170 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" + "github.com/truewhile/MeBox/internal/service/reader" +) + +// 阅读进度接口的 HTTP 契约测试。 +// +// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码, +// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉 +// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。 + +func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(middleware.CtxUserID, userID) + c.Next() + }) + registerReaderRoutes(r.Group("/api"), cfg) + return r +} + +func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + return w +} + +func TestSaveProgressAcceptsFractionalPos(t *testing.T) { + svc := newReaderHandlerContainer(t) + ctx := t.Context() + + book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{ + Origin: "https://example.com", + BookURL: "https://example.com/book/1", + }, "有声书", "作者", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + + router := progressRouterForTest(svc, "u1") + // 37.5 秒:音频进度上报的真实形态 + w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`) + if w.Code != http.StatusOK { + t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String()) + } + + books, err := svc.Reader.ListBooks(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if len(books) != 1 { + t.Fatalf("书架应有 1 本,实际 %d", len(books)) + } + // 小数秒截断成整数秒落库(dur_chapter_pos 是 int) + if books[0].DurChapterPos != 37 { + t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos) + } + if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" { + t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle) + } +} + +func TestSaveProgressAcceptsIntegerPos(t *testing.T) { + svc := newReaderHandlerContainer(t) + ctx := t.Context() + + book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{ + Origin: "https://example.com", + BookURL: "https://example.com/book/2", + }, "文本", "", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + + router := progressRouterForTest(svc, "u1") + // 文本/漫画仍是整数页码,不能被破坏 + w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`) + if w.Code != http.StatusOK { + t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String()) + } + + books, err := svc.Reader.ListBooks(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 { + t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex) + } +} + +func TestSaveProgressRejectsNegativePos(t *testing.T) { + svc := newReaderHandlerContainer(t) + ctx := t.Context() + + book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{ + Origin: "https://example.com", + BookURL: "https://example.com/book/3", + }, "文本", "", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + + router := progressRouterForTest(svc, "u1") + // 负数不是合法进度,落库前应被夹到 0,而不是写入负值 + w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`) + if w.Code != http.StatusOK { + t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String()) + } + + books, err := svc.Reader.ListBooks(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if books[0].DurChapterPos != 0 { + t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos) + } +} + +// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。 +func TestSaveProgressRoundTripsThroughJSON(t *testing.T) { + svc := newReaderHandlerContainer(t) + ctx := t.Context() + + book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{ + Origin: "https://example.com", + BookURL: "https://example.com/book/4", + }, "有声书", "", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + + router := progressRouterForTest(svc, "u1") + if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK { + t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String()) + } + + // 走列表接口(前端书架/阅读器恢复进度读的就是它) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil) + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String()) + } + var res struct { + Books []struct { + DurChapterPos int `json:"dur_chapter_pos"` + } `json:"books"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("解析书架响应失败: %v", err) + } + if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 { + t.Fatalf("读回的进度 = %+v,期望 123", res.Books) + } +} diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 23cdee7..ae2f008 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service" "github.com/truewhile/MeBox/internal/service/reader" ) @@ -57,6 +58,16 @@ 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("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc)) + + // 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步 + g.GET("/profile", readerGetProfileHandler(svc)) + g.PUT("/profile", readerSaveProfileHandler(svc)) + + // 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签 + g.GET("/book-groups", readerGetBookGroupsHandler(svc)) + g.PUT("/book-groups", readerSetBookGroupsHandler(svc)) // 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建) g.POST("/books/:id/origin", readerSwitchOriginHandler(svc)) g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc)) @@ -526,6 +537,20 @@ func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc { } } +// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。 +// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。 +func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetString(middleware.CtxUserID) + res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, res) + } +} + func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { userID := c.GetString(middleware.CtxUserID) @@ -537,11 +562,56 @@ func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc { } } -func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc { +// readerGetProfileHandler 读当前用户的阅读器偏好。 +// 没保存过时返回 {"profile": null},前端据此用本地值播种。 +func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetString(middleware.CtxUserID) + settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"profile": settings}) + } +} + +// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。 +func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc { + var body reader.ReaderSettings + 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) + saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"profile": saved}) + } +} + +// readerGetBookGroupsHandler 读当前用户的书架分组。 +// 没有分组时返回空数组(不是 null),前端可以直接遍历。 +func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetString(middleware.CtxUserID) + groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"groups": groups}) + } +} + +// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。 +func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc { var body struct { - ChapterIndex int `json:"chapter_index"` - Pos int `json:"pos"` - ChapterTitle string `json:"chapter_title"` + Groups []model.BookGroupSet `json:"groups"` } return func(c *gin.Context) { if err := c.ShouldBindJSON(&body); err != nil { @@ -549,7 +619,44 @@ func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc { return } userID := c.GetString(middleware.CtxUserID) - if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, body.Pos, body.ChapterTitle); err != nil { + groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"groups": groups}) + } +} + +// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它, +// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。 +const maxReaderChapterPos = 10_000_000 + +func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc { + var body struct { + ChapterIndex int `json:"chapter_index"` + // Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数), + // 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求 + // 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。 + Pos float64 `json:"pos"` + ChapterTitle string `json:"chapter_title"` + } + return func(c *gin.Context) { + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。 + posFloat := body.Pos + if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN) + posFloat = 0 + } + if posFloat > maxReaderChapterPos { + posFloat = maxReaderChapterPos + } + pos := int(posFloat) + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/model/model.go b/internal/model/model.go index 2a95c25..6869a9d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -66,5 +66,7 @@ func AllModels() []interface{} { &ReaderBook{}, &ReaderChapter{}, &ReaderReplaceRule{}, + &ReaderProfile{}, + &ReaderBookGroups{}, } } diff --git a/internal/model/reader.go b/internal/model/reader.go index 43bd8bb..daf6bc7 100644 --- a/internal/model/reader.go +++ b/internal/model/reader.go @@ -10,20 +10,20 @@ import ( // ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。 type ReaderBookSource struct { Base - Name string `gorm:"type:varchar(255);index" json:"name"` - GroupName string `gorm:"type:varchar(255);index" json:"group"` - Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频 - SourceURL string `gorm:"type:varchar(512);index" json:"source_url"` - RawJSON string `gorm:"type:text" json:"-"` - Enabled bool `gorm:"default:true" json:"enabled"` - EnabledExplore bool `gorm:"default:true" json:"enabled_explore"` - CustomOrder int `json:"custom_order"` - Weight int `json:"weight"` - ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"` - Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON - Comment string `gorm:"type:text" json:"comment"` - Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON - LastUpdateTime int64 `json:"last_update_time"` + Name string `gorm:"type:varchar(255);index" json:"name"` + GroupName string `gorm:"type:varchar(255);index" json:"group"` + Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频 + SourceURL string `gorm:"type:varchar(512);index" json:"source_url"` + RawJSON string `gorm:"type:text" json:"-"` + Enabled bool `gorm:"default:true" json:"enabled"` + EnabledExplore bool `gorm:"default:true" json:"enabled_explore"` + CustomOrder int `json:"custom_order"` + Weight int `json:"weight"` + ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"` + Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON + Comment string `gorm:"type:text" json:"comment"` + Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON + LastUpdateTime int64 `json:"last_update_time"` LastCheckAt *time.Time `json:"last_check_at"` RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms) // HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。 @@ -58,13 +58,17 @@ type ReaderBook struct { Charset string `gorm:"type:varchar(32)" json:"charset"` Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"` - TotalChapterNum int `json:"total_chapter_num"` - DurChapterIndex int `json:"dur_chapter_index"` - DurChapterPos int `json:"dur_chapter_pos"` - DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"` - DurChapterTime int64 `json:"dur_chapter_time"` - Order int `json:"order"` - Variable string `gorm:"type:text" json:"variable"` + // LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。 + // 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。 + // 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。 + LatestChapterTime int64 `json:"latest_chapter_time"` + TotalChapterNum int `json:"total_chapter_num"` + DurChapterIndex int `json:"dur_chapter_index"` + DurChapterPos int `json:"dur_chapter_pos"` + DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"` + DurChapterTime int64 `json:"dur_chapter_time"` + Order int `json:"order"` + Variable string `gorm:"type:text" json:"variable"` // LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名 // (如 ".txt");LocalExternal 为真时是服务器上的绝对路径。 // 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。 @@ -108,3 +112,31 @@ type ReaderReplaceRule struct { TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"` Order int `json:"order"` } + +// ReaderProfile 阅读器偏好(每个用户一条)。 +// +// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级: +// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。 +// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。 +type ReaderProfile struct { + Base + UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"` + + ThemeID string `gorm:"type:varchar(32)" json:"theme_id"` + Night bool `json:"night"` + PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动 + FontSize int `json:"font_size"` + LineHeight float64 `json:"line_height"` + ParagraphSpacing int `json:"paragraph_spacing"` + + // 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer) + AudioSpeed float64 `json:"audio_speed"` + AudioTimerMinutes int `json:"audio_timer_minutes"` + + // 书架展示偏好(对应 legado 的书架设置) + ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact + ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应 + ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"` + ShelfShowUnread bool `json:"shelf_show_unread"` + ShelfShowUpdateTime bool `json:"shelf_show_update_time"` +} diff --git a/internal/model/reader_group.go b/internal/model/reader_group.go new file mode 100644 index 0000000..769af87 --- /dev/null +++ b/internal/model/reader_group.go @@ -0,0 +1,125 @@ +package model + +import ( + "encoding/json" + "strings" +) + +// 书架分组(对应 legado 的 BookGroup)。 +// +// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。 +// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。 + +const ( + // MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。 + MaxBookGroups = 50 + // MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。 + MaxBookGroupNameLen = 24 +) + +// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。 +type BookGroupSet struct { + Name string `json:"name"` + BookIDs []string `json:"book_ids"` +} + +// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。 +// +// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON; +// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰: +// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。 +type ReaderBookGroups struct { + Base + UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"` + // Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。 + Groups string `gorm:"type:text" json:"-"` +} + +// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。 +func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet { + if r == nil || strings.TrimSpace(r.Groups) == "" { + return nil + } + var groups []BookGroupSet + if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil { + return nil + } + return NormalizeBookGroups(groups) +} + +// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。 +// 空集合序列化为空字符串,便于用零值表达「没有分组」。 +func EncodeBookGroups(groups []BookGroupSet) (string, error) { + if len(groups) == 0 { + return "", nil + } + raw, err := json.Marshal(groups) + if err != nil { + return "", err + } + return string(raw), nil +} + +// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的 +// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道 +// 书籍是否存在,属于服务层(SetBookGroups)的职责。 +func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet { + if len(groups) == 0 { + return nil + } + out := make([]BookGroupSet, 0, len(groups)) + indexByName := make(map[string]int, len(groups)) + for _, group := range groups { + name := TruncateBookGroupName(group.Name) + if name == "" { + continue + } + if len(out) >= MaxBookGroups { + break + } + key := strings.ToLower(name) + pos, exists := indexByName[key] + if !exists { + out = append(out, BookGroupSet{Name: name, BookIDs: []string{}}) + pos = len(out) - 1 + indexByName[key] = pos + } + seen := make(map[string]struct{}, len(out[pos].BookIDs)) + for _, id := range out[pos].BookIDs { + seen[id] = struct{}{} + } + for _, id := range group.BookIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out[pos].BookIDs = append(out[pos].BookIDs, trimmed) + } + } + if len(out) == 0 { + return nil + } + for i := range out { + if out[i].BookIDs == nil { + out[i].BookIDs = []string{} + } + } + return out +} + +// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。 +func TruncateBookGroupName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + runes := []rune(name) + if len(runes) > MaxBookGroupNameLen { + runes = runes[:MaxBookGroupNameLen] + } + return strings.TrimSpace(string(runes)) +} diff --git a/internal/model/reader_group_test.go b/internal/model/reader_group_test.go new file mode 100644 index 0000000..e6cae03 --- /dev/null +++ b/internal/model/reader_group_test.go @@ -0,0 +1,84 @@ +package model + +import ( + "strings" + "testing" +) + +// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。 + +func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) { + groups := []BookGroupSet{ + {Name: " 科幻 ", BookIDs: []string{"a", "b"}}, + {Name: "科幻", BookIDs: []string{"b", "c"}}, + {Name: " ", BookIDs: []string{"d"}}, + {Name: "在读", BookIDs: []string{"", "e", "e"}}, + } + got := NormalizeBookGroups(groups) + if len(got) != 2 { + t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got) + } + if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" { + t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0]) + } + if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" { + t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1]) + } +} + +func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) { + long := strings.Repeat("长", MaxBookGroupNameLen+10) + got := NormalizeBookGroups([]BookGroupSet{{Name: long}}) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen { + t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen) + } + + many := make([]BookGroupSet, 0, MaxBookGroups+5) + for i := 0; i < MaxBookGroups+5; i++ { + many = append(many, BookGroupSet{Name: string(rune('a' + i))}) + } + if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups { + t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups) + } +} + +func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) { + got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}}) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + // 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸 + if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 { + t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs) + } +} + +func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) { + if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" { + t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err) + } + + raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}}) + if err != nil { + t.Fatalf("EncodeBookGroups: %v", err) + } + row := &ReaderBookGroups{Groups: raw} + decoded := row.DecodeBookGroups() + if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" { + t.Fatalf("DecodeBookGroups = %#v", decoded) + } + + row.Groups = "{not json" + if decoded := row.DecodeBookGroups(); decoded != nil { + t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded) + } + + // nil 接收者不应 panic(仓库层查不到记录时会返回 nil) + var nilRow *ReaderBookGroups + if decoded := nilRow.DecodeBookGroups(); decoded != nil { + t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded) + } +} diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index 4fa2278..dd62e4b 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -192,6 +192,63 @@ func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index return &out, nil } +// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil), +// 由调用方决定是否用前端上送的当前值播种。 +func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) { + var out model.ReaderProfile + err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &out, nil +} + +// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。 +func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error { + var existing model.ReaderProfile + err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.WithContext(ctx).Create(p).Error + } + if err != nil { + return err + } + p.ID = existing.ID + p.CreatedAt = existing.CreatedAt + return r.db.WithContext(ctx).Save(p).Error +} + +// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。 +func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) { + var out model.ReaderBookGroups + err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &out, nil +} + +// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。 +func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error { + var existing model.ReaderBookGroups + err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return r.db.WithContext(ctx).Create(row).Error + } + if err != nil { + return err + } + row.ID = existing.ID + row.CreatedAt = existing.CreatedAt + return r.db.WithContext(ctx).Save(row).Error +} + // ListReplaceRules 用户替换规则(按 order 排序)。 func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) { var out []model.ReaderReplaceRule diff --git a/internal/service/reader/group.go b/internal/service/reader/group.go new file mode 100644 index 0000000..982b677 --- /dev/null +++ b/internal/service/reader/group.go @@ -0,0 +1,111 @@ +package reader + +import ( + "context" + "fmt" + + "github.com/truewhile/MeBox/internal/model" +) + +// 书架分组(对应 legado BookGroup;存储见 model.ReaderBookGroups)。 +// +// 语义与影视模块的媒体库标签一致:整份替换、一本书只归一个组、组内顺序即展示顺序。 +// 额外的两条收敛放在服务层,因为它们需要知道书籍是否存在: +// - 只保留该用户书架上的书(书被移出书架后分组里不留死 ID); +// - 一本书只归一个组(越靠前的分组优先)。 +// 空分组会保留:用户可能就是先建好分组再往里放书。 + +// GetBookGroups 读用户的书架分组。没有分组时返回空切片(非 nil),方便前端直接遍历。 +func (s *ReaderService) GetBookGroups(ctx context.Context, userID string) ([]model.BookGroupSet, error) { + if userID == "" { + return []model.BookGroupSet{}, nil + } + row, err := s.repo.GetBookGroups(ctx, userID) + if err != nil { + return nil, err + } + groups := model.NormalizeBookGroups(row.DecodeBookGroups()) + if len(groups) == 0 { + return []model.BookGroupSet{}, nil + } + owned, err := s.ownedBookIDs(ctx, userID) + if err != nil { + return nil, err + } + return filterBookGroups(groups, owned), nil +} + +// SetBookGroups 覆盖保存用户的书架分组,落库前做过收敛与归属过滤。 +func (s *ReaderService) SetBookGroups(ctx context.Context, userID string, groups []model.BookGroupSet) ([]model.BookGroupSet, error) { + if userID == "" { + return nil, fmt.Errorf("缺少用户信息") + } + owned, err := s.ownedBookIDs(ctx, userID) + if err != nil { + return nil, err + } + normalized := dedupeBookGroups(filterBookGroups(model.NormalizeBookGroups(groups), owned)) + raw, err := model.EncodeBookGroups(normalized) + if err != nil { + return nil, err + } + if err := s.repo.SaveBookGroups(ctx, &model.ReaderBookGroups{UserID: userID, Groups: raw}); err != nil { + return nil, err + } + if len(normalized) == 0 { + return []model.BookGroupSet{}, nil + } + return normalized, nil +} + +// ownedBookIDs 返回该用户书架上的书籍 ID 集合。 +func (s *ReaderService) ownedBookIDs(ctx context.Context, userID string) (map[string]struct{}, error) { + books, err := s.repo.ListBooks(ctx, userID) + if err != nil { + return nil, err + } + out := make(map[string]struct{}, len(books)) + for i := range books { + out[books[i].ID] = struct{}{} + } + return out, nil +} + +// filterBookGroups 只保留 owned 里的书籍 ID;空分组原样保留。 +func filterBookGroups(groups []model.BookGroupSet, owned map[string]struct{}) []model.BookGroupSet { + if len(groups) == 0 { + return nil + } + out := make([]model.BookGroupSet, 0, len(groups)) + for _, group := range groups { + ids := make([]string, 0, len(group.BookIDs)) + for _, id := range group.BookIDs { + if _, ok := owned[id]; ok { + ids = append(ids, id) + } + } + out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids}) + } + return out +} + +// dedupeBookGroups 收敛成「一本书只归一个组」:越靠前的分组优先,后面的重复项被移除。 +func dedupeBookGroups(groups []model.BookGroupSet) []model.BookGroupSet { + if len(groups) == 0 { + return nil + } + claimed := make(map[string]struct{}) + out := make([]model.BookGroupSet, 0, len(groups)) + for _, group := range groups { + ids := make([]string, 0, len(group.BookIDs)) + for _, id := range group.BookIDs { + if _, ok := claimed[id]; ok { + continue + } + claimed[id] = struct{}{} + ids = append(ids, id) + } + out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids}) + } + return out +} diff --git a/internal/service/reader/profile.go b/internal/service/reader/profile.go new file mode 100644 index 0000000..d19145c --- /dev/null +++ b/internal/service/reader/profile.go @@ -0,0 +1,190 @@ +package reader + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/truewhile/MeBox/internal/model" +) + +// 阅读器偏好(每个用户一条,见 model.ReaderProfile)。 +// +// 这些值原本只存在浏览器 localStorage,属于设备级;现在按用户落库,让主题、 +// 排版、听书、书架展示设置能跨设备一致。服务端只做「存储 + 收敛」, +// 具体语义(哪些主题、怎么排版)仍由前端决定,所以这里只校验取值范围与枚举, +// 不解释它们。 + +// 取值范围与前端 readerSettings store 里的 clamp 完全一致,两边改动需同步。 +const ( + readerFontSizeMin, readerFontSizeMax = 14, 32 + readerLineHeightMin, readerLineHeightMax = 1.4, 2.6 + readerParaSpacingMin, readerParaSpacingMax = 0, 32 + readerAudioSpeedMin, readerAudioSpeedMax = 0.5, 3 + readerAudioTimerMin, readerAudioTimerMax = 0, 180 + readerGridColumnsMin, readerGridColumnsMax = 0, 6 + + // readerThemeIDDefault 主题标识的自愈值:themeId 不枚举校验(前端可以自由 + // 新增主题),只限制长度,空值时回落到内置的「预设1」。 + readerThemeIDDefault = "preset1" + readerThemeIDMaxLen = 32 +) + +// 前端定义的枚举取值;未知值一律回落成默认项,避免脏值把阅读器置于不可用状态。 +var ( + readerPageModes = []string{"page", "scroll"} + readerShelfLayout = []string{"grid", "list", "compact"} + readerShelfSorts = []string{"recent", "update", "mixed", "name", "author", "manual"} +) + +// ReaderSettings 阅读器偏好的读写载荷(与前端 ReaderSettingsProfile 字段一一对应)。 +type ReaderSettings struct { + ThemeID string `json:"theme_id"` + Night bool `json:"night"` + PageMode string `json:"page_mode"` + FontSize int `json:"font_size"` + LineHeight float64 `json:"line_height"` + ParagraphSpacing int `json:"paragraph_spacing"` + + AudioSpeed float64 `json:"audio_speed"` + AudioTimerMinutes int `json:"audio_timer_minutes"` + + ShelfLayout string `json:"shelf_layout"` + ShelfGridColumns int `json:"shelf_grid_columns"` + ShelfSort string `json:"shelf_sort"` + ShelfShowUnread bool `json:"shelf_show_unread"` + ShelfShowUpdateTime bool `json:"shelf_show_update_time"` +} + +// GetReaderSettings 读用户阅读器偏好。 +// 该用户还没保存过时返回 (nil, nil):由前端用它本地的值播种,避免这里再维护一份默认值。 +func (s *ReaderService) GetReaderSettings(ctx context.Context, userID string) (*ReaderSettings, error) { + if userID == "" { + return nil, nil + } + row, err := s.repo.GetReaderProfile(ctx, userID) + if err != nil || row == nil { + return nil, err + } + out := settingsFromProfile(*row) + return &out, nil +} + +// SaveReaderSettings 覆盖保存用户阅读器偏好,落库前先做范围收敛。 +func (s *ReaderService) SaveReaderSettings(ctx context.Context, userID string, in ReaderSettings) (*ReaderSettings, error) { + if userID == "" { + return nil, fmt.Errorf("缺少用户信息") + } + sanitized := sanitizeReaderSettings(in) + row := &model.ReaderProfile{UserID: userID} + applySettingsToProfile(row, sanitized) + if err := s.repo.SaveReaderProfile(ctx, row); err != nil { + return nil, err + } + out := settingsFromProfile(*row) + return &out, nil +} + +// sanitizeReaderSettings 把入参收敛到合法范围:数值夹到区间、枚举未知即回落默认、 +// 主题标识去空白并限长。 +func sanitizeReaderSettings(in ReaderSettings) ReaderSettings { + return ReaderSettings{ + ThemeID: sanitizeThemeID(in.ThemeID), + Night: in.Night, + PageMode: oneOf(in.PageMode, readerPageModes, readerPageModes[0]), + FontSize: clampInt(in.FontSize, readerFontSizeMin, readerFontSizeMax), + LineHeight: roundTo1(clampFloat(in.LineHeight, readerLineHeightMin, readerLineHeightMax)), + ParagraphSpacing: clampInt(in.ParagraphSpacing, readerParaSpacingMin, readerParaSpacingMax), + + AudioSpeed: roundTo1(clampFloat(in.AudioSpeed, readerAudioSpeedMin, readerAudioSpeedMax)), + AudioTimerMinutes: clampInt(in.AudioTimerMinutes, readerAudioTimerMin, readerAudioTimerMax), + + ShelfLayout: oneOf(in.ShelfLayout, readerShelfLayout, readerShelfLayout[0]), + ShelfGridColumns: clampInt(in.ShelfGridColumns, readerGridColumnsMin, readerGridColumnsMax), + ShelfSort: oneOf(in.ShelfSort, readerShelfSorts, readerShelfSorts[0]), + ShelfShowUnread: in.ShelfShowUnread, + ShelfShowUpdateTime: in.ShelfShowUpdateTime, + } +} + +func sanitizeThemeID(v string) string { + v = strings.TrimSpace(v) + if v == "" || len(v) > readerThemeIDMaxLen { + return readerThemeIDDefault + } + return v +} + +func settingsFromProfile(p model.ReaderProfile) ReaderSettings { + return ReaderSettings{ + ThemeID: p.ThemeID, + Night: p.Night, + PageMode: p.PageMode, + FontSize: p.FontSize, + LineHeight: p.LineHeight, + ParagraphSpacing: p.ParagraphSpacing, + + AudioSpeed: p.AudioSpeed, + AudioTimerMinutes: p.AudioTimerMinutes, + + ShelfLayout: p.ShelfLayout, + ShelfGridColumns: p.ShelfGridColumns, + ShelfSort: p.ShelfSort, + ShelfShowUnread: p.ShelfShowUnread, + ShelfShowUpdateTime: p.ShelfShowUpdateTime, + } +} + +func applySettingsToProfile(p *model.ReaderProfile, in ReaderSettings) { + p.ThemeID = in.ThemeID + p.Night = in.Night + p.PageMode = in.PageMode + p.FontSize = in.FontSize + p.LineHeight = in.LineHeight + p.ParagraphSpacing = in.ParagraphSpacing + + p.AudioSpeed = in.AudioSpeed + p.AudioTimerMinutes = in.AudioTimerMinutes + + p.ShelfLayout = in.ShelfLayout + p.ShelfGridColumns = in.ShelfGridColumns + p.ShelfSort = in.ShelfSort + p.ShelfShowUnread = in.ShelfShowUnread + p.ShelfShowUpdateTime = in.ShelfShowUpdateTime +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func clampFloat(v, lo, hi float64) float64 { + // JSON 不会给出 NaN,这里是防御性的(例如将来接了别的调用方) + if math.IsNaN(v) || v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func roundTo1(v float64) float64 { + return math.Round(v*10) / 10 +} + +// oneOf 返回 v(当它属于 allowed 时),否则返回 fallback。 +func oneOf(v string, allowed []string, fallback string) string { + for _, a := range allowed { + if v == a { + return v + } + } + return fallback +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index ba38f0e..6e38894 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -610,24 +610,24 @@ func srcNameOf(src *model.ReaderBookSource, bs *BookSource) string { // SearchBook 搜索结果项(对应 legado SearchBook)。 type SearchBook struct { - Name string `json:"name"` - Author string `json:"author"` - Kind string `json:"kind"` - WordCount string `json:"word_count"` - LatestChapter string `json:"latest_chapter"` - Intro string `json:"intro"` - CoverURL string `json:"cover_url"` - BookURL string `json:"book_url"` + Name string `json:"name"` + Author string `json:"author"` + Kind string `json:"kind"` + WordCount string `json:"word_count"` + LatestChapter string `json:"latest_chapter"` + Intro string `json:"intro"` + CoverURL string `json:"cover_url"` + BookURL string `json:"book_url"` Origins []SearchOrigin `json:"origins"` } // SearchOrigin 命中该书目的书源(换源用)。 type SearchOrigin struct { - SourceID string `json:"source_id"` - Origin string `json:"origin"` - OriginName string `json:"origin_name"` - OriginType int `json:"origin_type"` - BookURL string `json:"book_url"` + SourceID string `json:"source_id"` + Origin string `json:"origin"` + OriginName string `json:"origin_name"` + OriginType int `json:"origin_type"` + BookURL string `json:"book_url"` LatestChapter string `json:"latest_chapter"` } @@ -975,10 +975,10 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo // TocChapter 目录章节项。 type TocChapter struct { - Index int `json:"index"` - Title string `json:"title"` - URL string `json:"url"` - IsVolume bool `json:"is_volume"` + Index int `json:"index"` + Title string `json:"title"` + URL string `json:"url"` + IsVolume bool `json:"is_volume"` UpdateTime string `json:"update_time"` } @@ -1016,22 +1016,53 @@ func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, } // 对应 legado:书源给 book.type 赋值后 legado 会持久化到 Book.type。 // 书架的「开始阅读」与详情页都会在这里拉目录,此时书籍已在书架时即可写回。 - s.applyDeclaredBookType(ctx, userID, src.SourceURL, bookURL, declared) + // 顺便把末章标题与「最近更新」时间写回,供书架显示与排序。 + s.applyTocMeta(ctx, userID, src.SourceURL, bookURL, declared, chapters) return chapters, nil } -// applyDeclaredBookType 把书源声明的书籍类型写回书架记录。 -func (s *ReaderService) applyDeclaredBookType(ctx context.Context, userID, origin, bookURL string, declared int) { - if declared < 0 || userID == "" || origin == "" || bookURL == "" { +// latestChapterTitleOf 取目录里最后一个非卷章节的标题(对应 legado 的「最新章节」)。 +// 目录为空或全是卷名时返回空串。 +func latestChapterTitleOf(chapters []TocChapter) string { + for i := len(chapters) - 1; i >= 0; i-- { + if !chapters[i].IsVolume { + return strings.TrimSpace(chapters[i].Title) + } + } + return "" +} + +// applyTocMeta 把目录阶段得到的信息写回书架记录: +// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时); +// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。 +// +// 书籍不在书架(搜索/详情预览)时直接跳过。首次记录末章标题不算「更新」, +// 只有原本已有标题、且新标题不同,才认为书源这边出现了新章节。 +func (s *ReaderService) applyTocMeta(ctx context.Context, userID, origin, bookURL string, declared int, chapters []TocChapter) { + if userID == "" || origin == "" || bookURL == "" { return } book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL) - if err != nil || book == nil || book.Type == declared { + if err != nil || book == nil { + return + } + dirty := false + if declared >= 0 && book.Type != declared { + book.Type = declared + dirty = true + } + if latest := latestChapterTitleOf(chapters); latest != "" && latest != book.LatestChapterTitle { + if book.LatestChapterTitle != "" { + book.LatestChapterTime = time.Now().UnixMilli() + } + book.LatestChapterTitle = latest + dirty = true + } + if !dirty { return } - book.Type = declared if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { - s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err)) + s.log.Warn("reader: 写回目录信息失败", zap.String("book", book.ID), zap.Error(err)) } } @@ -1568,6 +1599,91 @@ 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"` // 抓取或写入失败的书 +} + +// refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。 +const refreshTocConcurrency = 4 + +// RefreshBooksToc 刷新用户书架里全部网络书籍的目录(对应 legado 的「更新目录」菜单)。 +// +// 逐本重新抓目录、覆盖章节缓存;末章变化时由 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 { + return nil, err + } + res := &TocRefreshResult{} + targets := make([]model.ReaderBook, 0, len(books)) + for i := range books { + b := books[i] + if b.LocalPath != "" || b.BookURL == "" || b.Origin == "" { + continue + } + targets = append(targets, b) + } + res.Total = len(targets) + if res.Total == 0 { + return res, nil + } + + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, refreshTocConcurrency) + for i := range targets { + b := targets[i] + wg.Add(1) + sem <- struct{}{} + go func(b model.ReaderBook) { + defer wg.Done() + defer func() { <-sem }() + bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout) + defer cancel() + if s.refreshBookToc(bookCtx, userID, b) { + mu.Lock() + res.Updated++ + mu.Unlock() + return + } + mu.Lock() + res.Failed++ + mu.Unlock() + }(b) + } + wg.Wait() + return res, nil +} + +// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。 +func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool { + 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 + } + 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 + } + beforeCount := before[book.ID] + return beforeCount > 0 && len(chapters) > beforeCount +} + // ListReplaceRules 用户替换规则列表。 func (s *ReaderService) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) { return s.repo.ListReplaceRules(ctx, userID) diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts index 76eadce..259b9a0 100644 --- a/web/src/api/reader.ts +++ b/web/src/api/reader.ts @@ -1,4 +1,7 @@ import { LONG_REQUEST_TIMEOUT, api } from './client' +// 只引类型:阅读器偏好的字段定义在 store(它是本地状态的形状), +// 这里作为 /reader/profile 的线上载荷复用,类型导入不会产生运行时依赖。 +import type { ReaderSettingsProfile } from '../stores/readerSettings' // 阅读子系统 API(/api/reader/*),字段与后端 model/reader.go 对齐。 @@ -59,6 +62,8 @@ export interface ReaderBook { intro: string type: number latest_chapter_title: string + /** 最后一次检测到「目录末尾章节变化」的时间(毫秒,0 表示未检测到更新)。 */ + latest_chapter_time: number total_chapter_num: number dur_chapter_index: number dur_chapter_pos: number @@ -74,6 +79,12 @@ export interface ReaderBook { close_credits: number } +/** 书架分组(对齐影视模块的媒体库标签):组名 + 组内书籍 ID,顺序即组内展示顺序。 */ +export interface BookGroup { + name: string + book_ids: string[] +} + export interface ReaderBookInfo { name: string author: string @@ -283,6 +294,31 @@ export const readerAPI = { .post('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT }) .then((r) => r.data), removeBook: (id: string) => api.delete(`/reader/books/${id}`), + /** + * 读取当前账号的阅读器偏好(主题 / 排版 / 听书 / 书架展示)。 + * 从未保存过时返回 null,由前端用本地的值播种。 + */ + getReaderSettings: () => + api.get<{ profile: ReaderSettingsProfile | null }>('/reader/profile').then((r) => r.data.profile), + /** 覆盖保存阅读器偏好(服务端会做范围收敛)。 */ + saveReaderSettings: (body: ReaderSettingsProfile) => + api.put<{ profile: ReaderSettingsProfile }>('/reader/profile', body).then((r) => r.data.profile), + + // ── 书架分组(每个用户一份,仿影视模块的媒体库标签)── + /** 读取当前账号的书架分组;没有分组时返回空数组。 */ + getBookGroups: () => + api.get<{ groups: BookGroup[] | null }>('/reader/book-groups').then((r) => r.data.groups ?? []), + /** 覆盖保存书架分组:服务端会收敛成「一书一组」并丢弃已不在书架上的书。 */ + setBookGroups: (groups: BookGroup[]) => + api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []), + /** + * 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录, + * 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。 + */ + refreshBooksToc: () => + api + .post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts) + .then((r) => r.data), /** * 换源:把书架里的书切到另一个书源。 * 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。 diff --git a/web/src/components/BookCardMenu.tsx b/web/src/components/BookCardMenu.tsx new file mode 100644 index 0000000..b20cfbd07 --- /dev/null +++ b/web/src/components/BookCardMenu.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { FolderInput, Info, MoreVertical, Trash2 } from 'lucide-react' + +import type { ReaderBook } from '../api/reader' +import { bookDetailPath } from '../pages/reader/bookshelfModel' + +/** + * 书籍卡片上的「⋯」操作菜单:把「移到分组 / 书籍详情 / 移出书架」收进一个下拉里, + * 不再让三个图标直接铺在封面上。 + * + * 定位上有三点刻意的处理: + * 1. 菜单用 `position: fixed` + 实测位置渲染(而不是绝对定位挂在卡片里)。 + * 网格卡片为了裁圆角带 `overflow-hidden`,绝对定位的子元素会被裁掉;固定定位能 + * 逃出这个裁剪,也不用引入 portal。 + * 2. 网格布局下菜单与书籍卡片**同宽、左边缘对齐**、向下展开,看起来像卡片自己展开的 + * 动作面板;宽度取自卡片(`data-book-card` 标记的元素),列数变化时自动跟随。 + * 3. 下方确实放不下时才向上翻,并夹在视口内,避免贴底部的卡片点开看不见。 + */ +export function BookCardMenu({ + book, + onPickGroup, + onRemove, + variant = 'overlay', +}: { + book: ReaderBook + onPickGroup: () => void + onRemove: () => void + /** overlay:网格封面上的圆形按钮;plain:列表布局右侧的浅色按钮。 */ + variant?: 'overlay' | 'plain' +}) { + const navigate = useNavigate() + const [open, setOpen] = useState(false) + // 进场动画用:先以「缩起来」渲染一帧,再切到展开态触发过渡 + const [shown, setShown] = useState(false) + const [pos, setPos] = useState<{ top: number; left: number; width: number; up: boolean } | null>(null) + const btnRef = useRef(null) + + /** 列表布局(一行很宽)不适合跟行同宽,保持固定宽度。 */ + const PLAIN_WIDTH = 176 + /** 网格卡片过窄时给个下限,保证文字不挤成两行。 */ + const MIN_WIDTH = 132 + /** 三个菜单项 + 分隔线的大致高度,用来判断下方是否放得下。 */ + const MENU_HEIGHT = 140 + const GAP = 8 + const EDGE = 8 + + const openMenu = () => { + const el = btnRef.current + if (!el) return + const btnRect = el.getBoundingClientRect() + + // 网格:取卡片本身的位置与宽度(由 BookshelfCard 打上 data-book-card 标记) + const cardEl = variant === 'overlay' ? el.closest('[data-book-card]') : null + const cardRect = cardEl instanceof HTMLElement ? cardEl.getBoundingClientRect() : null + + const width = cardRect + ? Math.max(MIN_WIDTH, Math.min(cardRect.width, window.innerWidth - EDGE * 2)) + : PLAIN_WIDTH + const maxLeft = Math.max(EDGE, window.innerWidth - width - EDGE) + const left = cardRect + ? Math.min(Math.max(EDGE, cardRect.left), maxLeft) + : Math.min(Math.max(EDGE, btnRect.right - width), maxLeft) + + // 默认向下;只有下方真的放不下才向上翻 + const spaceBelow = window.innerHeight - btnRect.bottom + const up = spaceBelow < MENU_HEIGHT + GAP + EDGE && btnRect.top >= MENU_HEIGHT + GAP + EDGE + const top = up + ? Math.max(EDGE, btnRect.top - GAP - MENU_HEIGHT) + : Math.min(btnRect.bottom + GAP, Math.max(EDGE, window.innerHeight - MENU_HEIGHT - EDGE)) + + setShown(false) + setPos({ top, left, width, up }) + setOpen(true) + } + + useEffect(() => { + if (!open) return + const close = () => setOpen(false) + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (btnRef.current?.contains(target)) return + // 菜单本身在 fixed 层里,点它不算「点外部」 + if (target instanceof Element && target.closest('[data-book-card-menu]')) return + close() + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') close() + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown) + // 滚动/缩放后按钮位置会变,菜单会脱位,直接关掉更省事 + window.addEventListener('scroll', close, true) + window.addEventListener('resize', close) + return () => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown) + window.removeEventListener('scroll', close, true) + window.removeEventListener('resize', close) + } + }, [open]) + + // 挂载后下一帧再切到展开态,否则过渡不会触发 + useEffect(() => { + if (!open) return + const id = window.requestAnimationFrame(() => setShown(true)) + return () => window.cancelAnimationFrame(id) + }, [open]) + + const run = (action: () => void) => { + setOpen(false) + action() + } + + const triggerClass = + variant === 'overlay' + ? 'rounded-full bg-black/55 p-1.5 text-white opacity-85 backdrop-blur transition hover:bg-brand-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white' + : 'rounded-lg p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]' + + // 每行自己声明完整配色(含文字颜色),避免同类 utility 互相覆盖 + const itemBase = + 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-xs font-bold transition-colors' + const itemNormal = 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]' + const itemDanger = 'text-red-500/90 hover:bg-red-500/10 hover:text-red-500' + + return ( + <> + + + {open && pos && ( +
event.stopPropagation()} + > + + +
+ +
+ )} + + ) +} diff --git a/web/src/components/BookGroupBar.tsx b/web/src/components/BookGroupBar.tsx new file mode 100644 index 0000000..7e935e5 --- /dev/null +++ b/web/src/components/BookGroupBar.tsx @@ -0,0 +1,55 @@ +import { Library, ListFilter } from 'lucide-react' + +import type { BookGroupTab } from '../utils/readerBookGroups' + +/** + * 书架分组栏(对齐影视模块的 LibraryTagBar): + * 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。 + * + * 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 / + * 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。 + */ +export function BookGroupBar({ + tabs, + selectedGroupId, + onSelect, +}: { + tabs: BookGroupTab[] + selectedGroupId: string + onSelect: (groupId: string) => void +}) { + return ( +
+ {tabs.map((tab) => { + const active = tab.id === selectedGroupId + return ( + + ) + })} +
+ ) +} diff --git a/web/src/components/BookGroupPickerDialog.tsx b/web/src/components/BookGroupPickerDialog.tsx new file mode 100644 index 0000000..42fd6e1 --- /dev/null +++ b/web/src/components/BookGroupPickerDialog.tsx @@ -0,0 +1,91 @@ +import { Check, FolderInput, Layers, X } from 'lucide-react' + +import type { BookGroup, ReaderBook } from '../api/reader' + +/** + * 「移到分组」选择框:给一本书指定所属分组。 + * + * 书籍归组没有放在分组管理弹窗里 —— 书架可能有上千本,逐个列出来既慢又难找, + * 直接在卡片上点「分组」更顺手。一本书只归一个分组,选「未分组」即移出所有分组。 + */ +export function BookGroupPickerDialog({ + book, + groups, + currentGroupId, + busy = false, + onPick, + onClose, +}: { + book: ReaderBook + groups: BookGroup[] + /** 当前所属分组名;空串表示未分组。 */ + currentGroupId: string + busy?: boolean + onPick: (groupName: string) => void + onClose: () => void +}) { + return ( +
+
event.stopPropagation()} + > +
+
+

移到分组

+

《{book.name}》

+
+ +
+ +
+ + + {groups.length === 0 ? ( +

+ 还没有分组,先在书架分组栏的「管理分组」里新建一个 +

+ ) : ( + groups.map((group) => ( + + )) + )} +
+
+
+ ) +} diff --git a/web/src/components/ManageBookGroupsDialogView.tsx b/web/src/components/ManageBookGroupsDialogView.tsx new file mode 100644 index 0000000..dd93928 --- /dev/null +++ b/web/src/components/ManageBookGroupsDialogView.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from 'react' +import { Check, GripVertical, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { confirmAction } from './confirmAction' +import { useDragReorder } from '../hooks/useDragReorder' +import { MAX_BOOK_GROUPS, bookGroupNameError, normalizeBookGroupName } from '../utils/readerBookGroups' +import type { BookGroup } from '../api/reader' + +export type ManageBookGroupsDialogProps = { + groups: BookGroup[] + /** 分组名 → 当前书架里实际属于该组的书籍数(不统计已失效的 ID)。 */ + bookCounts: Record + saving: boolean + onCreate: (name: string) => Promise + onRename: (from: string, to: string) => Promise + onRemove: (name: string) => Promise + /** 按给定分组名顺序重排(拖拽排序的落点)。 */ + onReorder: (orderedNames: string[]) => Promise + onClose: () => void +} + +/** + * 分组管理对话框(对齐影视模块的管理标签弹窗,去掉成员分配那半边): + * 新建 / 重命名 / 删除分组,拖拽调整分组栏顺序。 + * + * 书籍归组不在这里做,而是在书架卡片上的「分组」按钮里逐个/批量指定 —— 书可能有上千本, + * 逐个列在弹窗里既慢又难找。 + */ +export function ManageBookGroupsDialogView({ + groups, + bookCounts, + saving, + onCreate, + onRename, + onRemove, + onReorder, + onClose, +}: ManageBookGroupsDialogProps) { + const [draftName, setDraftName] = useState('') + const [renaming, setRenaming] = useState('') + const [renameDraft, setRenameDraft] = useState('') + const { draggingId, dragOverId, dragProps } = useDragReorder( + groups.map((group) => group.name), + onReorder, + ) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const submitCreate = () => { + const name = normalizeBookGroupName(draftName) + if (!name) return + const err = bookGroupNameError(draftName, groups) + if (err) { + toast.error(err) + return + } + setDraftName('') + void onCreate(name) + } + + const submitRename = (from: string) => { + const name = normalizeBookGroupName(renameDraft) + setRenaming('') + setRenameDraft('') + if (!name || name === from) return + const err = bookGroupNameError(renameDraft, groups, from) + if (err) { + toast.error(err) + return + } + void onRename(from, name) + } + + const handleRemove = async (name: string) => { + const ok = await confirmAction({ + title: '删除分组', + message: `删除「${name}」后,组内的书会回到「未分组」,书籍本身不会被移除。确定吗?`, + confirmText: '删除', + danger: true, + }) + if (!ok) return + void onRemove(name) + } + + return ( +
+
event.stopPropagation()} + > +
+
+

管理分组

+

+ 拖拽调整顺序 · 最多 {MAX_BOOK_GROUPS} 个分组 +

+
+ +
+ +
+ setDraftName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitCreate() + } + }} + maxLength={24} + placeholder="新分组名" + className="h-9 min-w-0 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)] focus:border-brand-500" + /> + +
+ +
+ {groups.length === 0 ? ( +

+ 还没有分组,先在上方新建一个,再到书籍卡片上用「分组」按钮把书放进去 +

+ ) : ( +
+ {groups.map((group) => { + const isRenaming = renaming === group.name + const dragging = draggingId === group.name + const over = dragOverId === group.name + return ( +
+ + + + + {isRenaming ? ( + <> + setRenameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submitRename(group.name) + } + if (event.key === 'Escape') { + setRenaming('') + setRenameDraft('') + } + }} + maxLength={24} + className="h-7 min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500" + /> + + + + ) : ( + <> + + {group.name} + + + {bookCounts[group.name] ?? 0} 本 + + + + + )} +
+ ) + })} +
+ )} +
+ + {saving && ( +
+ 正在保存… +
+ )} +
+
+ ) +} diff --git a/web/src/components/ReaderBookCover.tsx b/web/src/components/ReaderBookCover.tsx index 1d7f792..8a4f1de 100644 --- a/web/src/components/ReaderBookCover.tsx +++ b/web/src/components/ReaderBookCover.tsx @@ -1,13 +1,17 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type SyntheticEvent } from 'react' import { BookOpen } from 'lucide-react' import { isRenderableCover } from '../utils/coverUrl' +import { readerCoverSrc } from '../utils/readerCover' // 阅读子系统统一封面图。 // // 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封), // 直接塞进 会显示破图。这里统一处理:地址不是图片、或加载失败时, // 一律回退成占位图标。 +// +// 另外,明文 http 的封面在 https 部署下会被混合内容策略拦掉,统一经后端图片 +// 代理下发(见 readerCoverSrc),否则这些封面会全部退化成占位图标。 export default function ReaderBookCover({ url, alt = '', @@ -25,7 +29,17 @@ export default function ReaderBookCover({ setFailed(false) }, [url]) - if (!isRenderableCover(url) || failed) { + const src = readerCoverSrc(url) + + // 后端图片代理抓不到上游时会回一张 1×1 透明 PNG(HTTP 200), 因此不会 + // 触发 onError。书源在「没有封面」时可能返回源站首页这类非图片地址,代理同样 + // 只能给出这张占位像素——按加载失败处理,才会退回图标而不是留下一块空白。 + const handleLoad = (e: SyntheticEvent) => { + const img = e.currentTarget + if (img.naturalWidth <= 1 && img.naturalHeight <= 1) setFailed(true) + } + + if (!isRenderableCover(src) || failed) { return (
@@ -35,11 +49,12 @@ export default function ReaderBookCover({ return ( {alt} setFailed(true)} /> ) diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index 499a6ab..8880828 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -1,12 +1,25 @@ -import { ReactNode } from 'react' +import { useEffect, type ReactNode } from 'react' import { Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../stores/auth' +import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync' // Route guard: redirects to /login when no token is present. export function RequireAuth({ children }: { children: ReactNode }) { const token = useAuthStore((s) => s.token) + const userId = useAuthStore((s) => s.user?.id ?? null) const location = useLocation() + + // 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在 + // 登录 / 换账号时重新拉取(这里同时覆盖影视外壳和 /reader/* 独立布局)。 + useEffect(() => { + startReaderSettingsSync() + }, []) + useEffect(() => { + if (!userId) return + void hydrateReaderSettings() + }, [userId]) + if (!token) { return } diff --git a/web/src/hooks/useBookGroups.ts b/web/src/hooks/useBookGroups.ts new file mode 100644 index 0000000..0e28931 --- /dev/null +++ b/web/src/hooks/useBookGroups.ts @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' + +import type { BookGroup } from '../api/reader' +import { + ALL_GROUP_ID, + assignBooksToGroup, + attachBookToGroup, + dedupeBookGroups, + detachBooksFromGroup, + loadBookGroups, + normalizeBookGroups, + normalizeBookGroupName, + readCachedBookGroups, + readSelectedGroupId, + reorderBookGroups, + resolveSelectedGroupId, + saveBookGroups, + writeCachedBookGroups, + writeSelectedGroupId, +} from '../utils/readerBookGroups' + +export type UseBookGroupsResult = { + /** 当前用户维护的分组(顺序即分组栏顺序)。 */ + groups: BookGroup[] + /** 分组数据是否仍在首次加载中。 */ + loading: boolean + /** 是否有写入在途(可用于禁用重复点击)。 */ + saving: boolean + /** 接口不可用(例如旧后端)时降级为本地分组。 */ + loadError: boolean + /** 当前选中的分组栏:ALL_GROUP_ID 表示「全部」。 */ + selectedGroupId: string + setSelectedGroupId: (groupId: string) => void + createGroup: (name: string) => Promise + renameGroup: (from: string, to: string) => Promise + /** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */ + reorderGroups: (orderedNames: string[]) => Promise + removeGroup: (name: string) => Promise + /** 覆盖某个分组下的书籍集合(保持传入顺序)。 */ + setGroupBooks: (name: string, bookIds: string[]) => Promise + /** 把一本书归到分组下;groupName 为空表示移出所有分组。 */ + assignBook: (bookId: string, groupName: string) => Promise + /** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */ + assignBooks: (bookIds: string[], groupName: string) => Promise +} + +/** + * 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。 + * + * 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`), + * 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的 + * 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。 + */ +export function useBookGroups(): UseBookGroupsResult { + const [groups, setGroups] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [loadError, setLoadError] = useState(false) + const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId()) + + const groupsRef = useRef([]) + const selectedRef = useRef(selectedGroupId) + const savedRef = useRef([]) + const chainRef = useRef>(Promise.resolve()) + const pendingRef = useRef(0) + // 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。 + const localOnlyRef = useRef(false) + + useEffect(() => { + groupsRef.current = groups + }, [groups]) + + useEffect(() => { + selectedRef.current = selectedGroupId + }, [selectedGroupId]) + + useEffect(() => { + let cancelled = false + setLoading(true) + loadBookGroups() + .then((rows) => { + if (cancelled) return + groupsRef.current = rows + savedRef.current = rows + setGroups(rows) + setLoadError(false) + }) + .catch((err) => { + if (cancelled) return + // 旧后端没有 /reader/book-groups:用本地缓存兜底,分组栏照常可用。 + const cached = readCachedBookGroups() + groupsRef.current = cached + savedRef.current = cached + setGroups(cached) + setLoadError(true) + localOnlyRef.current = isMissingEndpoint(err) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + // 选中的分组可能被删除,落回「全部」以免分组栏空白。 + // 首次加载完成前 groups 为空集合,此时不能据此判定分组已被删除。 + useEffect(() => { + if (loading) return + const resolved = resolveSelectedGroupId(groups, selectedGroupId) + if (resolved !== selectedGroupId) { + selectedRef.current = resolved + setSelectedGroupIdState(resolved) + } + }, [loading, groups, selectedGroupId]) + + const setSelectedGroupId = useCallback((groupId: string) => { + const next = groupId && groupId !== ALL_GROUP_ID ? groupId : ALL_GROUP_ID + selectedRef.current = next + writeSelectedGroupId(next) + setSelectedGroupIdState(next) + }, []) + + const mutate = useCallback((updater: (current: BookGroup[]) => BookGroup[]) => { + const rollback = savedRef.current + const next = dedupeBookGroups(normalizeBookGroups(updater(groupsRef.current))) + groupsRef.current = next + setGroups(next) + + pendingRef.current += 1 + setSaving(true) + const task = chainRef.current.then(async () => { + // 发送排队时的最新本地状态:链上更早的写入已被后面的状态取代。 + const snapshot = groupsRef.current + if (localOnlyRef.current) { + if (groupsRef.current === snapshot) savedRef.current = snapshot + writeCachedBookGroups(snapshot) + return + } + try { + const saved = await saveBookGroups(snapshot) + savedRef.current = saved + setLoadError(false) + if (groupsRef.current === snapshot) { + groupsRef.current = saved + setGroups(saved) + } + } catch (err) { + if (isMissingEndpoint(err)) { + // 旧后端不支持分组接口:本次修改保留在本地存储,分组栏照常可用。 + localOnlyRef.current = true + savedRef.current = snapshot + writeCachedBookGroups(snapshot) + setLoadError(true) + return + } + // 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的分组。 + groupsRef.current = rollback + savedRef.current = rollback + setGroups(rollback) + setLoadError(true) + toast.error('分组保存失败,请稍后重试') + } + }) + chainRef.current = task.catch(() => undefined) + return task.finally(() => { + pendingRef.current = Math.max(0, pendingRef.current - 1) + if (pendingRef.current === 0) setSaving(false) + }) + }, []) + + const createGroup = useCallback( + async (name: string) => { + const trimmed = normalizeBookGroupName(name) + if (!trimmed) return '' + if (groupsRef.current.some((group) => group.name.toLowerCase() === trimmed.toLowerCase())) { + return trimmed + } + await mutate((current) => [...current, { name: trimmed, book_ids: [] }]) + return groupsRef.current.some((group) => group.name === trimmed) ? trimmed : '' + }, + [mutate], + ) + + const renameGroup = useCallback( + async (from: string, to: string) => { + const trimmed = normalizeBookGroupName(to) + if (!trimmed || trimmed === from) return + await mutate((current) => + current.map((group) => (group.name === from ? { ...group, name: trimmed } : group)), + ) + // 重命名后选中项要跟着换到新名字,否则分组栏会落回「全部」。 + if (selectedRef.current === from) setSelectedGroupId(trimmed) + }, + [mutate, setSelectedGroupId], + ) + + const reorderGroups = useCallback( + async (orderedNames: string[]) => { + await mutate((current) => reorderBookGroups(current, orderedNames)) + }, + [mutate], + ) + + const removeGroup = useCallback( + async (name: string) => { + await mutate((current) => current.filter((group) => group.name !== name)) + if (selectedRef.current === name) setSelectedGroupId(ALL_GROUP_ID) + }, + [mutate, setSelectedGroupId], + ) + + const setGroupBooks = useCallback( + async (name: string, bookIds: string[]) => { + await mutate((current) => + current.map((group) => (group.name === name ? { ...group, book_ids: bookIds } : group)), + ) + }, + [mutate], + ) + + const assignBook = useCallback( + async (bookId: string, groupName: string) => { + await mutate((current) => + groupName ? attachBookToGroup(current, bookId, groupName) : detachBooksFromGroup(current, [bookId]), + ) + }, + [mutate], + ) + + const assignBooks = useCallback( + async (bookIds: string[], groupName: string) => { + await mutate((current) => assignBooksToGroup(current, bookIds, groupName)) + }, + [mutate], + ) + + return { + groups, + loading, + saving, + loadError, + selectedGroupId, + setSelectedGroupId, + createGroup, + renameGroup, + reorderGroups, + removeGroup, + setGroupBooks, + assignBook, + assignBooks, + } +} + +/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */ +function isMissingEndpoint(err: unknown): boolean { + const status = (err as { response?: { status?: number } })?.response?.status + return status === 404 || status === 405 || status === 501 +} diff --git a/web/src/pages/reader/BookshelfCard.tsx b/web/src/pages/reader/BookshelfCard.tsx new file mode 100644 index 0000000..217cd39 --- /dev/null +++ b/web/src/pages/reader/BookshelfCard.tsx @@ -0,0 +1,234 @@ +import { BookOpen, Clock, HardDrive, History, UserRound } from 'lucide-react' + +import type { ReaderBook } from '../../api/reader' +import { BookCardMenu } from '../../components/BookCardMenu' +import ReaderBookCover from '../../components/ReaderBookCover' +import type { ReaderShelfLayout } from '../../stores/readerSettings' +import { formatRelativeTime, readProgressText, shelfUpdateTime, unreadChapters } from './bookshelfModel' + +// 书架卡片:网格 / 列表 / 紧凑列表三种布局共用。 +// +// 信息层级对齐 legado 书架物品: +// 网格 —— 封面 + 本地角标 + 未读徽标 + 书名(两行) +// 列表 —— 封面 + 书名 + 作者 + 读到 + 最新章节 + 更新时间 + 未读徽标 +// 紧凑 —— 小封面 + 书名 + 作者 · 读到 +// 点卡片进阅读器;「详情」进书籍详情页;「移出」二次确认由调用方处理。 + +function UnreadBadge({ book, compact = false }: { book: ReaderBook; compact?: boolean }) { + const unread = unreadChapters(book) + if (unread === null) return null + return ( + 0 ? `还有 ${unread} 章未读` : '已读完'} + className={`shrink-0 whitespace-nowrap rounded-md font-bold ${ + compact ? 'px-1 py-px text-[10px]' : 'px-1.5 py-0.5 text-2xs' + } ${unread > 0 ? 'bg-rose-500/90 text-white' : 'bg-[var(--app-hover)] text-[var(--app-muted)]'}`} + > + {unread > 0 ? `${unread} 章未读` : '已读完'} + + ) +} + +function LocalBadge() { + return ( + + 本地 + + ) +} + +function GridCard({ + book, + showUnread, + onOpen, + onPickGroup, + onRemove, +}: { + book: ReaderBook + showUnread: boolean + onOpen: () => void + onPickGroup: () => void + onRemove: () => void +}) { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpen() + } + }} + 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.is_local && ( + + + + )} + {showUnread && unreadChapters(book) !== null && ( + + + + )} +
+ +
+
+

{book.name}

+
+ ) +} + +function ListCard({ + book, + showUnread, + showUpdateTime, + groupName = '', + compact = false, + onOpen, + onPickGroup, + onRemove, +}: { + book: ReaderBook + showUnread: boolean + showUpdateTime: boolean + /** 所属分组名;空串表示未分组。 */ + groupName?: string + compact?: boolean + onOpen: () => void + onPickGroup: () => void + onRemove: () => void +}) { + const read = readProgressText(book) + const updated = shelfUpdateTime(book) + const coverClass = compact ? 'h-16 w-12' : 'h-[90px] w-[66px]' + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpen() + } + }} + className={`group relative flex cursor-pointer gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-2.5 shadow-sm transition hover:border-brand-500/40 hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${ + compact ? 'items-center' : 'items-stretch' + }`} + > +
+ + {book.is_local && ( + + + + )} +
+ +
+
+

{book.name}

+ {showUnread && } +
+ + {compact ? ( +

+ {book.author || '佚名'} + {read && ( + <> + · + 读到 {read} + + )} +

+ ) : ( + <> +

+ + {book.author || '佚名'} + {groupName && ( + <> + · + + {groupName} + + + )} + {showUpdateTime && updated > 0 && ( + <> + · + + {formatRelativeTime(updated)} + + )} +

+

+ + {read ? `读到 ${read}` : '尚未开始阅读'} +

+

+ + + 最新 {book.latest_chapter_title || '未知'} + +

+ + )} +
+ +
+ +
+
+ ) +} + +export function BookshelfCard({ + book, + layout, + showUnread, + showUpdateTime, + groupName = '', + onOpen, + onPickGroup, + onRemove, +}: { + book: ReaderBook + layout: ReaderShelfLayout + showUnread: boolean + showUpdateTime: boolean + /** 所属分组名;空串表示未分组(只在列表布局里展示)。 */ + groupName?: string + onOpen: () => void + onPickGroup: () => void + onRemove: () => void +}) { + if (layout === 'grid') { + return ( + + ) + } + return ( + + ) +} diff --git a/web/src/pages/reader/BookshelfSettingsDialog.tsx b/web/src/pages/reader/BookshelfSettingsDialog.tsx new file mode 100644 index 0000000..49cb2f2 --- /dev/null +++ b/web/src/pages/reader/BookshelfSettingsDialog.tsx @@ -0,0 +1,200 @@ +import { Check, X } from 'lucide-react' +import type { ReactNode } from 'react' + +import { + SHELF_GRID_COLUMNS, + useReaderSettingsStore, +} from '../../stores/readerSettings' +import { SHELF_LAYOUT_OPTIONS, SHELF_SORT_OPTIONS, gridColumnsLabel } from './bookshelfModel' + +// 书架设置弹窗(对应 legado 的 dialog_bookshelf_config): +// 布局(列表 / 紧凑列表 / 网格 + 列数)、排序、以及显示项开关。 +// 所有选项直接落到 readerSettings store,关掉弹窗即生效。 + +function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { + return ( +
+

{title}

+ {hint &&

{hint}

} +
{children}
+
+ ) +} + +function RadioOption({ + active, + label, + hint, + onClick, +}: { + active: boolean + label: string + hint?: string + onClick: () => void +}) { + return ( + + ) +} + +function ToggleOption({ + active, + label, + hint, + onToggle, +}: { + active: boolean + label: string + hint?: string + onToggle: () => void +}) { + return ( + + ) +} + +export function BookshelfSettingsDialog({ onClose }: { onClose: () => void }) { + const layout = useReaderSettingsStore((s) => s.shelfLayout) + const setLayout = useReaderSettingsStore((s) => s.setShelfLayout) + const columns = useReaderSettingsStore((s) => s.shelfGridColumns) + const setColumns = useReaderSettingsStore((s) => s.setShelfGridColumns) + const sort = useReaderSettingsStore((s) => s.shelfSort) + const setSort = useReaderSettingsStore((s) => s.setShelfSort) + const showUnread = useReaderSettingsStore((s) => s.shelfShowUnread) + const setShowUnread = useReaderSettingsStore((s) => s.setShelfShowUnread) + const showUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime) + const setShowUpdateTime = useReaderSettingsStore((s) => s.setShelfShowUpdateTime) + + return ( +
+
event.stopPropagation()} + > +
+
+

书架设置

+

布局、排序与显示项

+
+ +
+ +
+
+
+ {SHELF_LAYOUT_OPTIONS.map((option) => ( + setLayout(option.value)} + /> + ))} +
+ {layout === 'grid' && ( +
+ 列数 + {SHELF_GRID_COLUMNS.map((value) => ( + + ))} +
+ )} +
+ +
+
+ {SHELF_SORT_OPTIONS.map((option) => ( + setSort(option.value)} + /> + ))} +
+
+ +
+
+ setShowUnread(!showUnread)} + /> + setShowUpdateTime(!showUpdateTime)} + /> +
+
+
+
+
+ ) +} diff --git a/web/src/pages/reader/ReaderAudioPanel.tsx b/web/src/pages/reader/ReaderAudioPanel.tsx index 4446732..491b89c 100644 --- a/web/src/pages/reader/ReaderAudioPanel.tsx +++ b/web/src/pages/reader/ReaderAudioPanel.tsx @@ -21,6 +21,7 @@ import { AUDIO_TIMERS, useReaderSettingsStore, } from '../../stores/readerSettings' +import { readerCoverSrc } from '../../utils/readerCover' // 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行): // hls.js 播 m3u8,