This commit is contained in:
truewhile
2026-10-02 11:05:59 +08:00
parent 42343212c5
commit 5acfa6292a
35 changed files with 4162 additions and 224 deletions
+24 -1
View File
@@ -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. 阅读界面(重点)
文件:`<src>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. 搜索
+240
View File
@@ -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 "<missing>"
}
// 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)
}
}
+248
View File
@@ -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)
}
}
+170
View File
@@ -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)
}
}
+112 -5
View File
@@ -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
}
+2
View File
@@ -66,5 +66,7 @@ func AllModels() []interface{} {
&ReaderBook{},
&ReaderChapter{},
&ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
}
}
+53 -21
View File
@@ -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 下的文件名
// (如 "<id>.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"`
}
+125
View File
@@ -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))
}
+84
View File
@@ -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)
}
}
+57
View File
@@ -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
+111
View File
@@ -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
}
+190
View File
@@ -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
}
+140 -24
View File
@@ -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)
+36
View File
@@ -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<ReaderBook>('/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),
/**
* 换源:把书架里的书切到另一个书源。
* 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
+184
View File
@@ -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<HTMLButtonElement>(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 (
<>
<button
ref={btnRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`更多操作:${book.name}`}
title="更多操作"
onClick={(event) => {
event.stopPropagation()
if (open) setOpen(false)
else openMenu()
}}
className={triggerClass}
>
<MoreVertical size={variant === 'overlay' ? 13 : 16} />
</button>
{open && pos && (
<div
data-book-card-menu=""
role="menu"
style={{
top: pos.top,
left: pos.left,
width: pos.width,
transformOrigin: pos.up ? 'bottom right' : 'top right',
}}
className={`fixed z-[120] rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1.5 shadow-2xl transition duration-100 ease-out ${
shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}
onClick={(event) => event.stopPropagation()}
>
<button type="button" role="menuitem" className={`${itemBase} ${itemNormal}`} onClick={() => run(onPickGroup)}>
<FolderInput size={14} className="shrink-0 text-brand-500" /> 移到分组
</button>
<button
type="button"
role="menuitem"
className={`${itemBase} ${itemNormal}`}
onClick={() => run(() => navigate(bookDetailPath(book)))}
>
<Info size={14} className="shrink-0 opacity-70" /> 书籍详情
</button>
<div className="my-1 border-t border-[var(--app-border)]" />
<button
type="button"
role="menuitem"
className={`${itemBase} ${itemDanger}`}
onClick={() => run(onRemove)}
>
<Trash2 size={14} className="shrink-0" /> 移出书架
</button>
</div>
)}
</>
)
}
+55
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-1.5 border-b border-[var(--app-border)] pb-3">
{tabs.map((tab) => {
const active = tab.id === selectedGroupId
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`}
title={
tab.kind === 'all'
? '显示全部书籍'
: tab.kind === 'ungrouped'
? '显示没有归入任何分组的书籍'
: `只看「${tab.name}」分组`
}
>
{tab.kind === 'all' && <Library size={13} />}
{tab.kind === 'ungrouped' && <ListFilter size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</span>
</button>
)
})}
</div>
)
}
@@ -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 (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label="移到分组"
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
<div className="min-w-0">
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">移到分组</h3>
<p className="mt-0.5 truncate text-2xs text-[var(--app-muted)]">《{book.name}》</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
<button
type="button"
disabled={busy}
onClick={() => onPick('')}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
>
<Layers size={14} className="shrink-0 text-[var(--app-muted)]" />
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">未分组</span>
{currentGroupId === '' && <Check size={14} className="shrink-0 text-brand-600" />}
</button>
{groups.length === 0 ? (
<p className="px-3 py-6 text-center text-2xs text-[var(--app-muted)]">
还没有分组,先在书架分组栏的「管理分组」里新建一个
</p>
) : (
groups.map((group) => (
<button
key={group.name}
type="button"
disabled={busy}
onClick={() => onPick(group.name)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
>
<FolderInput size={14} className="shrink-0 text-brand-500" />
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
{group.name}
</span>
{currentGroupId === group.name && <Check size={14} className="shrink-0 text-brand-600" />}
</button>
))
)}
</div>
</div>
</div>
)
}
@@ -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<string, number>
saving: boolean
onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 按给定分组名顺序重排(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
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 (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label="管理分组"
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
<div>
<h3 className="text-sm font-bold text-[var(--app-text)]">管理分组</h3>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
拖拽调整顺序 · 最多 {MAX_BOOK_GROUPS} 个分组
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-5 py-3">
<input
value={draftName}
onChange={(event) => 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"
/>
<button
type="button"
onClick={submitCreate}
disabled={saving || !draftName.trim() || groups.length >= MAX_BOOK_GROUPS}
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-[var(--app-border)] px-3 py-2 text-xs font-bold text-brand-600 transition hover:bg-[var(--app-hover)] disabled:opacity-40"
>
<Plus size={13} /> 新建
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groups.length === 0 ? (
<p className="py-10 text-center text-xs text-[var(--app-muted)]">
还没有分组,先在上方新建一个,再到书籍卡片上用「分组」按钮把书放进去
</p>
) : (
<div className="space-y-1">
{groups.map((group) => {
const isRenaming = renaming === group.name
const dragging = draggingId === group.name
const over = dragOverId === group.name
return (
<div
key={group.name}
{...dragProps(group.name)}
className={`flex items-center gap-2 rounded-xl border px-2 py-2 transition ${
dragging
? 'border-brand-500/60 opacity-60'
: over
? 'border-brand-500/60 bg-brand-500/5'
: 'border-transparent hover:bg-[var(--app-hover)]'
}`}
>
<span
className="shrink-0 cursor-grab p-1 text-[var(--app-muted)] active:cursor-grabbing"
title="拖拽调整顺序"
>
<GripVertical size={14} />
</span>
{isRenaming ? (
<>
<input
autoFocus
value={renameDraft}
onChange={(event) => 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"
/>
<button
type="button"
onClick={() => submitRename(group.name)}
className="rounded-lg p-1.5 text-brand-600 hover:bg-[var(--app-hover)]"
title="保存"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('')
setRenameDraft('')
}}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
{group.name}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{bookCounts[group.name] ?? 0} 本
</span>
<button
type="button"
onClick={() => {
setRenaming(group.name)
setRenameDraft(group.name)
}}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(group.name)}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-red-500/10 hover:text-red-500"
title="删除分组"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
)
})}
</div>
)}
</div>
{saving && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--app-border)] py-2 text-2xs text-[var(--app-muted)]">
<Loader2 size={12} className="animate-spin" /> 正在保存…
</div>
)}
</div>
</div>
)
}
+18 -3
View File
@@ -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: 参数信封),
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
// 一律回退成占位图标。
//
// 另外,明文 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),<img> 因此不会
// 触发 onError。书源在「没有封面」时可能返回源站首页这类非图片地址,代理同样
// 只能给出这张占位像素——按加载失败处理,才会退回图标而不是留下一块空白。
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) setFailed(true)
}
if (!isRenderableCover(src) || failed) {
return (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
@@ -35,11 +49,12 @@ export default function ReaderBookCover({
return (
<img
src={url ?? ''}
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
className={className}
onLoad={handleLoad}
onError={() => setFailed(true)}
/>
)
+14 -1
View File
@@ -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 <Navigate to="/login" replace state={{ from: location }} />
}
+260
View File
@@ -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<string>
renameGroup: (from: string, to: string) => Promise<void>
/** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */
reorderGroups: (orderedNames: string[]) => Promise<void>
removeGroup: (name: string) => Promise<void>
/** 覆盖某个分组下的书籍集合(保持传入顺序)。 */
setGroupBooks: (name: string, bookIds: string[]) => Promise<void>
/** 把一本书归到分组下;groupName 为空表示移出所有分组。 */
assignBook: (bookId: string, groupName: string) => Promise<void>
/** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */
assignBooks: (bookIds: string[], groupName: string) => Promise<void>
}
/**
* 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。
*
* 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`),
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的
* 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。
*/
export function useBookGroups(): UseBookGroupsResult {
const [groups, setGroups] = useState<BookGroup[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [loadError, setLoadError] = useState(false)
const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId())
const groupsRef = useRef<BookGroup[]>([])
const selectedRef = useRef(selectedGroupId)
const savedRef = useRef<BookGroup[]>([])
const chainRef = useRef<Promise<unknown>>(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
}
+234
View File
@@ -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 (
<span
title={unread > 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} 章未读` : '已读完'}
</span>
)
}
function LocalBadge() {
return (
<span className="flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
<HardDrive size={10} /> 本地
</span>
)
}
function GridCard({
book,
showUnread,
onOpen,
onPickGroup,
onRemove,
}: {
book: ReaderBook
showUnread: boolean
onOpen: () => void
onPickGroup: () => void
onRemove: () => void
}) {
return (
<div className="group">
<div
role="button"
tabIndex={0}
data-book-card=""
onClick={onOpen}
onKeyDown={(e) => {
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"
>
<div className="aspect-[3/4] w-full">
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
</div>
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是详情/移出按钮) */}
{book.is_local && (
<span className="absolute bottom-1 left-1">
<LocalBadge />
</span>
)}
{showUnread && unreadChapters(book) !== null && (
<span className="absolute right-1 top-1">
<UnreadBadge book={book} />
</span>
)}
<div className="absolute bottom-1 right-1">
<BookCardMenu book={book} variant="overlay" onPickGroup={onPickGroup} onRemove={onRemove} />
</div>
</div>
<p className="mt-2 line-clamp-2 text-xs font-bold leading-snug text-[var(--app-text)]">{book.name}</p>
</div>
)
}
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 (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
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'
}`}
>
<div className={`relative shrink-0 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] ${coverClass}`}>
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={compact ? 16 : 20} />
{book.is_local && (
<span className="absolute left-0.5 top-0.5 rounded-md bg-black/55 p-0.5 text-white backdrop-blur">
<HardDrive size={9} />
</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]">{book.name}</p>
{showUnread && <UnreadBadge book={book} compact={compact} />}
</div>
{compact ? (
<p className="flex min-w-0 items-center gap-1 truncate text-2xs text-[var(--app-muted)]">
<span className="truncate">{book.author || '佚名'}</span>
{read && (
<>
<span className="shrink-0">·</span>
<span className="truncate">读到 {read}</span>
</>
)}
</p>
) : (
<>
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
<UserRound size={11} className="shrink-0 opacity-60" />
<span className="truncate">{book.author || '佚名'}</span>
{groupName && (
<>
<span className="shrink-0">·</span>
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-px text-[10px] font-bold text-brand-600">
{groupName}
</span>
</>
)}
{showUpdateTime && updated > 0 && (
<>
<span className="shrink-0">·</span>
<Clock size={11} className="shrink-0 opacity-60" />
<span className="shrink-0">{formatRelativeTime(updated)}</span>
</>
)}
</p>
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
<History size={11} className="shrink-0 opacity-60" />
<span className="truncate">{read ? `读到 ${read}` : '尚未开始阅读'}</span>
</p>
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
<BookOpen size={11} className="shrink-0 opacity-60" />
<span className="truncate">
最新 {book.latest_chapter_title || '未知'}
</span>
</p>
</>
)}
</div>
<div className="flex shrink-0 items-center justify-center">
<BookCardMenu book={book} variant="plain" onPickGroup={onPickGroup} onRemove={onRemove} />
</div>
</div>
)
}
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 (
<GridCard book={book} showUnread={showUnread} onOpen={onOpen} onPickGroup={onPickGroup} onRemove={onRemove} />
)
}
return (
<ListCard
book={book}
compact={layout === 'compact'}
showUnread={showUnread}
showUpdateTime={showUpdateTime}
groupName={groupName}
onOpen={onOpen}
onPickGroup={onPickGroup}
onRemove={onRemove}
/>
)
}
@@ -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 (
<div className="border-t border-[var(--app-border)] px-5 py-4 first:border-t-0">
<p className="text-xs font-bold text-[var(--app-text)]">{title}</p>
{hint && <p className="mt-0.5 text-2xs text-[var(--app-muted)]">{hint}</p>}
<div className="mt-3">{children}</div>
</div>
)
}
function RadioOption({
active,
label,
hint,
onClick,
}: {
active: boolean
label: string
hint?: string
onClick: () => void
}) {
return (
<button
type="button"
role="radio"
aria-checked={active}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-left transition ${
active
? 'border-brand-500 bg-brand-500/10'
: 'border-[var(--app-border)] hover:bg-[var(--app-hover)]'
}`}
>
<span className="min-w-0 flex-1">
<span className={`block truncate text-xs font-bold ${active ? 'text-brand-600' : 'text-[var(--app-text)]'}`}>
{label}
</span>
{hint && <span className="block truncate text-2xs text-[var(--app-muted)]">{hint}</span>}
</span>
{active && <Check size={14} className="shrink-0 text-brand-600" />}
</button>
)
}
function ToggleOption({
active,
label,
hint,
onToggle,
}: {
active: boolean
label: string
hint?: string
onToggle: () => void
}) {
return (
<button
type="button"
role="switch"
aria-checked={active}
onClick={onToggle}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)]"
>
<span className="min-w-0 flex-1">
<span className="block text-xs font-bold text-[var(--app-text)]">{label}</span>
{hint && <span className="block text-2xs text-[var(--app-muted)]">{hint}</span>}
</span>
<span
className={`relative h-5 w-9 shrink-0 rounded-full transition ${active ? 'bg-brand-500' : 'bg-[var(--app-border)]'}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
active ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</button>
)
}
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 (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label="书架设置"
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
<div>
<h3 className="text-sm font-bold text-[var(--app-text)]">书架设置</h3>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">布局、排序与显示项</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<Section title="布局" hint="网格可再选列数,自适应会随屏幕宽度变化">
<div role="radiogroup" className="grid gap-2">
{SHELF_LAYOUT_OPTIONS.map((option) => (
<RadioOption
key={option.value}
active={layout === option.value}
label={option.label}
hint={option.hint}
onClick={() => setLayout(option.value)}
/>
))}
</div>
{layout === 'grid' && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-2xs text-[var(--app-muted)]">列数</span>
{SHELF_GRID_COLUMNS.map((value) => (
<button
key={value}
type="button"
onClick={() => setColumns(value)}
className={`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${
columns === value
? 'border-brand-500 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:bg-[var(--app-hover)]'
}`}
>
{gridColumnsLabel(value)}
</button>
))}
</div>
)}
</Section>
<Section title="排序">
<div role="radiogroup" className="grid grid-cols-2 gap-2">
{SHELF_SORT_OPTIONS.map((option) => (
<RadioOption
key={option.value}
active={sort === option.value}
label={option.label}
onClick={() => setSort(option.value)}
/>
))}
</div>
</Section>
<Section title="显示项">
<div className="grid gap-1">
<ToggleOption
active={showUnread}
label="未读章数"
hint="封面右上角显示还剩多少章没读"
onToggle={() => setShowUnread(!showUnread)}
/>
<ToggleOption
active={showUpdateTime}
label="更新时间"
hint="列表布局显示最近一次检测到新章节的时间"
onToggle={() => setShowUpdateTime(!showUpdateTime)}
/>
</div>
</Section>
</div>
</div>
</div>
)
}
+7 -4
View File
@@ -21,6 +21,7 @@ import {
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { readerCoverSrc } from '../../utils/readerCover'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
// hls.js 播 m3u8,<audio> 播直链。
@@ -119,6 +120,8 @@ export function ReaderAudioPanel({
useEffect(() => {
setCoverOK(true)
}, [cover])
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
const coverSrc = readerCoverSrc(cover)
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
const [timerLeft, setTimerLeft] = useState(0)
@@ -291,10 +294,10 @@ export function ReaderAudioPanel({
>
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
{cover && coverOK && (
{coverSrc && coverOK && (
<>
<img
src={cover}
src={coverSrc}
alt=""
aria-hidden
referrerPolicy="no-referrer"
@@ -316,9 +319,9 @@ export function ReaderAudioPanel({
style={{ borderColor: theme.accent + '66', color: theme.accent }}
>
<div className="h-full w-full overflow-hidden rounded-full">
{cover && coverOK ? (
{coverSrc && coverOK ? (
<img
src={cover}
src={coverSrc}
alt={title}
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
+395 -155
View File
@@ -1,24 +1,134 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react'
import {
BookOpen,
Check,
ChevronDown,
FileUp,
FolderOpen,
Headphones,
LayoutGrid,
List,
Loader2,
MoreHorizontal,
RefreshCw,
Rows3,
Settings2,
SlidersHorizontal,
} from 'lucide-react'
import { readerAPI, type ReaderBook } from '../../api/reader'
import { BookGroupBar } from '../../components/BookGroupBar'
import { BookGroupPickerDialog } from '../../components/BookGroupPickerDialog'
import { ManageBookGroupsDialogView } from '../../components/ManageBookGroupsDialogView'
import { confirmAction } from '../../components/confirmAction'
import ReaderBookCover from '../../components/ReaderBookCover'
import { useBookGroups } from '../../hooks/useBookGroups'
import { useAuthStore } from '../../stores/auth'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { buildBookGroupTabs, filterBooksByGroup, groupIdOfBook, ALL_GROUP_ID } from '../../utils/readerBookGroups'
import { BookshelfCard } from './BookshelfCard'
import { BookshelfSettingsDialog } from './BookshelfSettingsDialog'
import { SHELF_SORT_OPTIONS, sortShelfBooks } from './bookshelfModel'
import { ReaderModeSwitch } from './ReaderModeSwitch'
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。
// 结构仿 legado 书架:顶部操作区 + 分组栏 + 网格/列表/紧凑列表三套布局 +
// 可配置排序与显示项。布局、排序、显示项存在 readerSettings store;
// 分组按用户存在服务端(见 utils/readerBookGroups.ts、hooks/useBookGroups.ts)。
// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。
// total_chapter_num 为 0 表示目录尚未缓存,无法计算。
function unreadChapters(book: ReaderBook): number | null {
if (book.total_chapter_num <= 0) return null
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
// 网格列数 → Tailwind 栅格类。窄屏保留 2–3 列兜底,避免固定列数在手机上挤成一团。
const GRID_COLUMNS_CLASS: Record<number, string> = {
0: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
2: 'grid-cols-2',
3: 'grid-cols-2 sm:grid-cols-3',
4: 'grid-cols-3 sm:grid-cols-4',
5: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5',
6: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6',
}
/** 工具栏下拉菜单:自管开合、点外部或 Esc 关闭。 */
function ToolbarMenu({
trigger,
title,
disabled = false,
width = 'w-44',
children,
}: {
trigger: ReactNode
title?: string
disabled?: boolean
width?: string
children: (close: () => void) => ReactNode
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
return (
<div className="relative" ref={ref}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
title={title}
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)] disabled:opacity-60"
>
{trigger}
</button>
{open && (
<div
role="menu"
className={`absolute right-0 z-30 mt-1.5 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl ${width}`}
>
{children(() => setOpen(false))}
</div>
)}
</div>
)
}
function MenuItem({
icon,
children,
onClick,
disabled = false,
}: {
icon?: ReactNode
children: ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
>
{icon}
{children}
</button>
)
}
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
@@ -28,26 +138,42 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
const [error, setError] = useState('')
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [manageGroupsOpen, setManageGroupsOpen] = useState(false)
const [groupPickerBook, setGroupPickerBook] = useState<ReaderBook | null>(null)
const [refreshing, setRefreshing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 下拉菜单:点外部或按 Esc 关闭
useEffect(() => {
if (!menuOpen) return
const onPointerDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
const shelfLayout = useReaderSettingsStore((s) => s.shelfLayout)
const shelfSort = useReaderSettingsStore((s) => s.shelfSort)
const setShelfSort = useReaderSettingsStore((s) => s.setShelfSort)
const shelfGridColumns = useReaderSettingsStore((s) => s.shelfGridColumns)
const shelfShowUnread = useReaderSettingsStore((s) => s.shelfShowUnread)
const shelfShowUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime)
const sortedBooks = useMemo(() => (books ? sortShelfBooks(books, shelfSort) : null), [books, shelfSort])
const sortLabel = SHELF_SORT_OPTIONS.find((o) => o.value === shelfSort)?.label ?? '最近阅读'
// ── 书架分组(仿影视模块的媒体库标签)──
const bookGroups = useBookGroups()
const groupTabs = useMemo(
() => buildBookGroupTabs(books ?? [], bookGroups.groups),
[books, bookGroups.groups],
)
// 先按分组过滤,再按当前排序方式排;两者互不影响。
const visibleBooks = useMemo(
() => (sortedBooks ? filterBooksByGroup(sortedBooks, bookGroups.groups, bookGroups.selectedGroupId) : null),
[sortedBooks, bookGroups.groups, bookGroups.selectedGroupId],
)
// 管理弹窗里每个分组的书籍数只统计书架上真实存在的书。
const groupBookCounts = useMemo(() => {
const counts: Record<string, number> = {}
const available = new Set((books ?? []).map((b) => b.id))
for (const group of bookGroups.groups) {
counts[group.name] = group.book_ids.filter((id) => available.has(id)).length
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [menuOpen])
return counts
}, [books, bookGroups.groups])
const load = () => {
setError('')
@@ -114,14 +240,52 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
}
}
// 更新目录(对应 legado 书架的「更新目录」):重抓全部网络书籍的目录,刷出最新章节。
const refreshToc = async () => {
setRefreshing(true)
const toastId = toast.loading('正在更新目录…')
try {
const res = await readerAPI.refreshBooksToc()
load()
if (res.total === 0) {
toast.success('没有需要更新的网络书籍', { id: toastId })
} else if (res.failed > 0) {
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
} else {
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
}
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '更新目录失败', {
id: toastId,
})
} finally {
setRefreshing(false)
}
}
const assignBookToGroup = async (book: ReaderBook, groupName: string) => {
setGroupPickerBook(null)
await bookGroups.assignBook(book.id, groupName)
}
const inSpecificGroup = bookGroups.selectedGroupId !== ALL_GROUP_ID
return (
<div className="space-y-6">
{!embedded && <ReaderModeSwitch />}
{/* 标题右侧一个下拉入口,收纳全部书架操作 */}
<div className="flex items-center justify-between gap-3">
<h1 className="shrink-0 font-display text-2xl text-ink-600">书架</h1>
<div className="relative" ref={menuRef}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="shrink-0 font-display text-2xl text-ink-600">
书架
{visibleBooks !== null && visibleBooks.length > 0 && (
<span className="ml-2 align-middle text-xs font-normal text-[var(--app-muted)]">
{visibleBooks.length} 本
{inSpecificGroup && books !== null && <span className="ml-1">/ 共 {books.length} 本</span>}
</span>
)}
</h1>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
@@ -132,81 +296,148 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
if (f) void upload(f)
}}
/>
{/* 排序(对应 legado 书架设置的排序项) */}
<ToolbarMenu
title="排序方式"
trigger={
<>
<SlidersHorizontal size={13} />
<span className="hidden sm:inline">{sortLabel}</span>
<ChevronDown size={13} />
</>
}
>
{(close) => (
<>
{SHELF_SORT_OPTIONS.map((option) => (
<MenuItem
key={option.value}
icon={shelfSort === option.value ? <Check size={13} /> : <span className="w-[13px]" />}
onClick={() => {
setShelfSort(option.value)
close()
}}
>
{option.label}
</MenuItem>
))}
</>
)}
</ToolbarMenu>
{/* 布局 / 显示设置 */}
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
title="书架操作"
aria-label="书架设置"
title="书架设置:布局、排序、显示项"
onClick={() => setSettingsOpen(true)}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)]"
>
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
{uploading !== null ? `上传中 ${uploading}%` : '管理'}
<ChevronDown size={13} className={`transition-transform ${menuOpen ? 'rotate-180' : ''}`} />
{shelfLayout === 'grid' ? <LayoutGrid size={14} /> : shelfLayout === 'list' ? <List size={14} /> : <Rows3 size={14} />}
<Settings2 size={13} />
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-30 mt-1.5 w-40 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl"
>
<button
type="button"
role="menuitem"
disabled={uploading !== null}
title="上传 TXT / EPUB 到服务器阅读"
onClick={() => {
setMenuOpen(false)
fileRef.current?.click()
}}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
>
<FileUp size={13} /> 本地导入
</button>
{isAdmin && (
<>
<button
type="button"
role="menuitem"
disabled={uploading !== null}
title="选择服务器上已有的 TXT / EPUB 文件导入(原地引用,不复制)"
onClick={() => {
setMenuOpen(false)
setPicker('book')
}}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
>
<FolderOpen size={13} /> 服务器导入
</button>
<button
type="button"
role="menuitem"
disabled={uploading !== null}
title="选择服务器上的一个目录导入为有声书(含 .strm 播放指针)"
onClick={() => {
setMenuOpen(false)
setPicker('audio')
}}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
>
<Headphones size={13} /> 有声书导入
</button>
</>
)}
<div className="my-1 border-t border-[var(--app-border)]" />
<Link
to="/reader/sources"
role="menuitem"
onClick={() => setMenuOpen(false)}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Settings2 size={13} /> 书源管理
</Link>
</div>
)}
{/* 书架操作 */}
<ToolbarMenu
title="书架操作"
width="w-44"
disabled={uploading !== null}
trigger={
<>
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
<span className="hidden sm:inline">{uploading !== null ? `上传中 ${uploading}%` : '管理'}</span>
<ChevronDown size={13} />
</>
}
>
{(close) => (
<>
<MenuItem
icon={<FileUp size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
fileRef.current?.click()
}}
>
本地导入
</MenuItem>
{isAdmin && (
<>
<MenuItem
icon={<FolderOpen size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
setPicker('book')
}}
>
服务器导入
</MenuItem>
<MenuItem
icon={<Headphones size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
setPicker('audio')
}}
>
有声书导入
</MenuItem>
</>
)}
<MenuItem
icon={refreshing ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
disabled={refreshing}
onClick={() => {
close()
void refreshToc()
}}
>
更新目录
</MenuItem>
<MenuItem
icon={<SlidersHorizontal size={13} />}
onClick={() => {
close()
setSettingsOpen(true)
}}
>
书架设置
</MenuItem>
<MenuItem
icon={<Settings2 size={13} />}
onClick={() => {
close()
setManageGroupsOpen(true)
}}
>
管理分组
</MenuItem>
<div className="my-1 border-t border-[var(--app-border)]" />
<Link
to="/reader/sources"
role="menuitem"
onClick={close}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Settings2 size={13} /> 书源管理
</Link>
</>
)}
</ToolbarMenu>
</div>
</div>
{books !== null && books.length > 0 && (
<BookGroupBar
tabs={groupTabs}
selectedGroupId={bookGroups.selectedGroupId}
onSelect={bookGroups.setSelectedGroupId}
/>
)}
{books === null && !error && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
@@ -254,66 +485,75 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
</div>
)}
{books !== null && books.length > 0 && (
<div className="grid grid-cols-3 gap-x-4 gap-y-6 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{books.map((book) => {
const unread = unreadChapters(book)
return (
<div key={book.id} className="group">
<div
role="button"
tabIndex={0}
onClick={() => navigate(`/reader/view/${book.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigate(`/reader/view/${book.id}`)
}
}}
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<div className="aspect-[3/4] w-full">
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
</div>
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
{book.is_local && (
<span className="absolute bottom-1 left-1 flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
<HardDrive size={10} /> 本地
</span>
)}
{unread !== null && (
<span
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${
unread > 0 ? 'bg-rose-500/90' : 'bg-black/55'
}`}
>
{unread > 0 ? `${unread} 章未读` : '已读完'}
</span>
)}
<button
type="button"
aria-label={`移出书架:${book.name}`}
title="移出书架"
onClick={(e) => {
e.stopPropagation()
void removeFromShelf(book)
}}
className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
>
<Trash2 size={11} />
</button>
</div>
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
<p className="truncate text-2xs text-[var(--app-muted)]">
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
</p>
</div>
)
})}
{books !== null && books.length > 0 && visibleBooks !== null && visibleBooks.length === 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-10 text-center">
<p className="text-sm font-bold text-[var(--app-text)]">这个分组还没有书</p>
<p className="mt-1 text-xs text-[var(--app-muted)]">
在书籍卡片上点「移到分组」把书放进来,或切到「未分组」看看还没归组的书
</p>
</div>
)}
{visibleBooks !== null && visibleBooks.length > 0 && (
shelfLayout === 'grid' ? (
<div className={`grid gap-x-4 gap-y-6 ${GRID_COLUMNS_CLASS[shelfGridColumns] ?? GRID_COLUMNS_CLASS[0]}`}>
{visibleBooks.map((book) => (
<BookshelfCard
key={book.id}
book={book}
layout="grid"
showUnread={shelfShowUnread}
showUpdateTime={shelfShowUpdateTime}
onOpen={() => navigate(`/reader/view/${book.id}`)}
onPickGroup={() => setGroupPickerBook(book)}
onRemove={() => void removeFromShelf(book)}
/>
))}
</div>
) : (
<div className="space-y-2">
{visibleBooks.map((book) => (
<BookshelfCard
key={book.id}
book={book}
layout={shelfLayout}
showUnread={shelfShowUnread}
showUpdateTime={shelfShowUpdateTime}
groupName={groupIdOfBook(bookGroups.groups, book.id)}
onOpen={() => navigate(`/reader/view/${book.id}`)}
onPickGroup={() => setGroupPickerBook(book)}
onRemove={() => void removeFromShelf(book)}
/>
))}
</div>
)
)}
{settingsOpen && <BookshelfSettingsDialog onClose={() => setSettingsOpen(false)} />}
{manageGroupsOpen && (
<ManageBookGroupsDialogView
groups={bookGroups.groups}
bookCounts={groupBookCounts}
saving={bookGroups.saving}
onCreate={bookGroups.createGroup}
onRename={bookGroups.renameGroup}
onRemove={bookGroups.removeGroup}
onReorder={bookGroups.reorderGroups}
onClose={() => setManageGroupsOpen(false)}
/>
)}
{groupPickerBook && (
<BookGroupPickerDialog
book={groupPickerBook}
groups={bookGroups.groups}
currentGroupId={groupIdOfBook(bookGroups.groups, groupPickerBook.id)}
onPick={(groupName) => void assignBookToGroup(groupPickerBook, groupName)}
onClose={() => setGroupPickerBook(null)}
/>
)}
{picker && (
<ServerFilePickerDialog
mode={picker === 'audio' ? 'dir' : 'file'}
+26 -9
View File
@@ -30,6 +30,11 @@ const COLUMN_GAP = 48
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
const IMG_MARK = '[img]'
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
const AUDIO_SAVE_INTERVAL_MS = 5_000
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
const COMIC_SAVE_INTERVAL_MS = 2_000
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
const MENU_SHIFT = 'transition-transform duration-200'
@@ -172,13 +177,16 @@ export default function ReaderViewPage() {
setComicPage(0)
setCurrentImage(0)
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
const savedPos = Math.max(0, Math.floor(pendingPosRef.current))
if (ct.type !== 'text') {
setRestorePos(pendingPosRef.current)
setRestorePos(savedPos)
pendingPosRef.current = 0
}
// 进度上报(pos 保留原值,排版完成后才被消费清零)
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
// 刚恢复的进度又写成 0,退出重进就从头开始。
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
if (!contentCache.current.has(String(chapterIndex + 1))) {
@@ -246,23 +254,31 @@ export default function ReaderViewPage() {
}
}, [content, settings.pageMode])
// ── 进度保存(翻页 / 滚动) ──
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
const savePos = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
const ch = chapters[chapterIndex]
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos, chapter_title: ch?.title ?? '' })
.saveProgress(book.id, {
chapter_index: chapterIndex,
pos: Math.max(0, Math.floor(pos)),
chapter_title: ch?.title ?? '',
})
.catch(() => undefined)
},
[book, chapterIndex, chapters],
)
useEffect(() => {
if (settings.pageMode !== 'page' || content === null || chapterIndex === null) return
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
const t = setTimeout(() => savePos(page), 1500)
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, savePos])
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
// 漫画单页进度保存
useEffect(() => {
@@ -271,12 +287,13 @@ export default function ReaderViewPage() {
return () => clearTimeout(t)
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
// 音频/漫画滚动:节流进度保存
// 音频/漫画滚动:节流进度保存(听书 5s、漫画 2s)
const throttledMediaSave = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
const now = Date.now()
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
const interval = contentType === 'audio' ? AUDIO_SAVE_INTERVAL_MS : COMIC_SAVE_INTERVAL_MS
if (now - lastMediaSaveRef.current < interval) return
lastMediaSaveRef.current = now
savePos(pos)
},
@@ -0,0 +1,78 @@
import type { ReaderBook } from '../../api/reader'
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`bookshelfModel: ${name}`)
}
function book(overrides: Partial<ReaderBook>): ReaderBook {
return {
id: overrides.id ?? 'x',
origin: '',
origin_name: '',
book_url: '',
toc_url: '',
name: '书',
author: '',
kind: '',
cover_url: '',
intro: '',
type: 0,
latest_chapter_title: '',
latest_chapter_time: 0,
total_chapter_num: 0,
dur_chapter_index: 0,
dur_chapter_pos: 0,
dur_chapter_title: '',
dur_chapter_time: 0,
order: 0,
is_local: false,
local_external: false,
open_credits: 0,
close_credits: 0,
...overrides,
} as ReaderBook
}
// ── 未读章数 ──
check('no cached toc → null', unreadChapters(book({ total_chapter_num: 0, dur_chapter_index: 3 })) === null)
check('unread book counts all chapters', unreadChapters(book({ total_chapter_num: 10 })) === 10)
check(
'reading chapter 3 of 10 leaves 7',
unreadChapters(book({ total_chapter_num: 10, dur_chapter_index: 2, dur_chapter_time: 1 })) === 7,
)
check(
'progress past the end clamps to 0',
unreadChapters(book({ total_chapter_num: 5, dur_chapter_index: 99, dur_chapter_time: 1 })) === 0,
)
// dur_chapter_time 为 0 表示还没开始读,即使 index > 0 也不该算作已读
check('zero dur_chapter_time means unread', unreadChapters(book({ total_chapter_num: 4, dur_chapter_index: 2 })) === 4)
// ── 排序 ──
const a = book({ id: 'a', name: 'β', dur_chapter_time: 100, latest_chapter_time: 500, order: 2 })
const b = book({ id: 'b', name: 'α', dur_chapter_time: 300, latest_chapter_time: 100, order: 1 })
const c = book({ id: 'c', name: 'γ', dur_chapter_time: 0, latest_chapter_time: 900, order: 3 })
check('recent sorts by read time desc', sortShelfBooks([a, b, c], 'recent').map((x) => x.id).join('') === 'bac')
check('update sorts by latest chapter time desc', sortShelfBooks([a, b, c], 'update').map((x) => x.id).join('') === 'cab')
check('manual sorts by order asc', sortShelfBooks([a, b, c], 'manual').map((x) => x.id).join('') === 'bac')
check('name sorts by title', sortShelfBooks([a, b, c], 'name').map((x) => x.id).join('') === 'bac')
check(
'mixed uses the newer of read/update time',
sortShelfBooks([a, b, c], 'mixed').map((x) => x.id).join('') === 'cab',
)
check('sort does not mutate the input', [a, b, c].map((x) => x.id).join('') === 'abc')
// 作者相同按书名兜底
const d1 = book({ id: 'd1', name: 'B', author: '同一作者' })
const d2 = book({ id: 'd2', name: 'A', author: '同一作者' })
check('author then title', sortShelfBooks([d1, d2], 'author').map((x) => x.id).join('') === 'd2d1')
// ── 时间格式化 ──
check('zero time → empty', formatRelativeTime(0) === '')
check('just now', formatRelativeTime(Date.now() - 5_000) === '刚刚')
check('minutes ago', formatRelativeTime(Date.now() - 5 * 60_000) === '5 分钟前')
check('hours ago', formatRelativeTime(Date.now() - 3 * 3_600_000) === '3 小时前')
check('days ago', formatRelativeTime(Date.now() - 2 * 86_400_000) === '2 天前')
console.log('bookshelfModel.test.ts ok')
+123
View File
@@ -0,0 +1,123 @@
// 书架展示与排序的纯逻辑(不依赖 React,便于单测)。
//
// 语义对齐 legado 书架:未读章数取自 getUnreadChapterNum,「最近更新」取自
// Book.latestChapterTime,排序项与 AppConfig.getBookSortByGroupId 的六个取值一一对应。
import type { ReaderBook } from '../../api/reader'
import type { ReaderShelfLayout, ReaderShelfSort } from '../../stores/readerSettings'
/** 未读章数:total_chapter_num 为 0 表示目录尚未缓存,返回 null(前端不显示徽标)。 */
export function unreadChapters(book: ReaderBook): number | null {
if (book.total_chapter_num <= 0) return null
// dur_chapter_time 为 0 表示还没开始读,否则读到 dur_chapter_index 为止。
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
}
/** 书籍的「最近更新」时间(毫秒),未检测到更新时为 0。 */
export function shelfUpdateTime(book: ReaderBook): number {
return Number.isFinite(book.latest_chapter_time) ? book.latest_chapter_time : 0
}
function byTitle(a: ReaderBook, b: ReaderBook): number {
return a.name.localeCompare(b.name, 'zh-Hans-CN')
}
function byAuthor(a: ReaderBook, b: ReaderBook): number {
return (a.author || '').localeCompare(b.author || '', 'zh-Hans-CN') || byTitle(a, b)
}
/**
* 按所选方式排序书架,不修改入参。
*
* - recent:最近阅读(dur_chapter_time 倒序,没读过的按书名排后面)——legado 默认
* - update:最近更新(latest_chapter_time 倒序)
* - name / author:书名 / 作者
* - mixed:综合(阅读时间与更新时间取较新者)
* - manual:手动顺序(服务端 order 字段)
*/
export function sortShelfBooks(books: ReaderBook[], sort: ReaderShelfSort): ReaderBook[] {
const list = [...books]
switch (sort) {
case 'update':
return list.sort(
(a, b) => shelfUpdateTime(b) - shelfUpdateTime(a) || b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b),
)
case 'name':
return list.sort(byTitle)
case 'author':
return list.sort(byAuthor)
case 'mixed':
return list.sort(
(a, b) =>
Math.max(shelfUpdateTime(b), b.dur_chapter_time) - Math.max(shelfUpdateTime(a), a.dur_chapter_time) ||
byTitle(a, b),
)
case 'manual':
return list.sort((a, b) => a.order - b.order || byTitle(a, b))
case 'recent':
default:
return list.sort((a, b) => b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b))
}
}
/** 绝对时间:当天只显示时分,同年显示月日,跨年带年份。 */
export function formatBookTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return ''
const d = new Date(ms)
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 相对时间:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 30 天退回日期。 */
export function formatRelativeTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return ''
const diff = Date.now() - ms
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
return formatBookTime(ms)
}
/** 「读到」文案;未开始时返回空串。 */
export function readProgressText(book: ReaderBook): string {
if (book.dur_chapter_time <= 0 || !book.dur_chapter_title) return ''
return book.dur_chapter_title
}
export const SHELF_LAYOUT_OPTIONS: { value: ReaderShelfLayout; label: string; hint: string }[] = [
{ value: 'grid', label: '网格', hint: '封面宫格,可调列数' },
{ value: 'list', label: '列表', hint: '封面 + 书名 + 读到 / 最新章节' },
{ value: 'compact', label: '紧凑列表', hint: '小封面单行,一屏放更多书' },
]
export const SHELF_SORT_OPTIONS: { value: ReaderShelfSort; label: string }[] = [
{ value: 'recent', label: '最近阅读' },
{ value: 'update', label: '最近更新' },
{ value: 'mixed', label: '综合' },
{ value: 'name', label: '按书名' },
{ value: 'author', label: '按作者' },
{ value: 'manual', label: '手动顺序' },
]
/** 网格列数选项的展示文案。 */
export function gridColumnsLabel(columns: number): string {
return columns === 0 ? '自适应' : `${columns} 列`
}
/** 书籍详情页地址(/reader/book),与搜索结果的跳转参数保持一致。 */
export function bookDetailPath(book: ReaderBook): string {
const params = new URLSearchParams({
source_url: book.origin,
book_url: book.book_url,
name: book.name,
author: book.author || '',
cover_url: book.cover_url || '',
origin_name: book.origin_name || '',
})
return `/reader/book?${params.toString()}`
}
+109
View File
@@ -33,16 +33,46 @@ export function getReaderTheme(themeId: string, night: boolean): { bg: string; t
export type ReaderPageMode = 'page' | 'scroll'
export type ReaderHomeMode = 'media' | 'reading'
/** 书架布局:网格封面 / 常规列表 / 紧凑列表(对应 legado AppConfig.bookshelfLayout)。 */
export type ReaderShelfLayout = 'grid' | 'list' | 'compact'
/**
* 书架排序(对应 legado AppConfig 的 bookSort):
* recent=最近阅读、update=最近更新、name=书名、author=作者、mixed=综合、manual=手动顺序。
*/
export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed' | 'manual'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
setHomeMode: (mode: ReaderHomeMode) => void
// ── 书架布局与展示(对应 legado 的书架设置)──
/** 布局样式。 */
shelfLayout: ReaderShelfLayout
setShelfLayout: (layout: ReaderShelfLayout) => void
/** 网格列数,0 为自适应。 */
shelfGridColumns: number
setShelfGridColumns: (columns: number) => void
/** 排序方式。 */
shelfSort: ReaderShelfSort
setShelfSort: (sort: ReaderShelfSort) => void
/** 是否显示未读章数徽标。 */
shelfShowUnread: boolean
setShelfShowUnread: (show: boolean) => void
/** 是否显示「更新时间」一行(仅列表布局)。 */
shelfShowUpdateTime: boolean
setShelfShowUpdateTime: (show: boolean) => void
themeId: string
setThemeId: (id: string) => void
night: boolean
toggleNight: () => void
setNight: (night: boolean) => void
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
@@ -80,11 +110,28 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
homeMode: 'media',
setHomeMode: (mode) => set({ homeMode: mode }),
shelfLayout: 'grid',
setShelfLayout: (shelfLayout) => set({ shelfLayout }),
shelfGridColumns: 0,
setShelfGridColumns: (shelfGridColumns) =>
set({ shelfGridColumns: Math.min(6, Math.max(0, Math.round(shelfGridColumns))) }),
shelfSort: 'recent',
setShelfSort: (shelfSort) => set({ shelfSort }),
shelfShowUnread: true,
setShelfShowUnread: (shelfShowUnread) => set({ shelfShowUnread }),
shelfShowUpdateTime: true,
setShelfShowUpdateTime: (shelfShowUpdateTime) => set({ shelfShowUpdateTime }),
themeId: 'preset1',
setThemeId: (themeId) => set({ themeId }),
night: false,
toggleNight: () => set((s) => ({ night: !s.night })),
setNight: (night) => set({ night }),
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
@@ -110,3 +157,65 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
{ name: 'mebox-reader-settings' },
),
)
/**
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
*/
export interface ReaderSettingsProfile {
theme_id: string
night: boolean
page_mode: ReaderPageMode
font_size: number
line_height: number
paragraph_spacing: number
audio_speed: number
audio_timer_minutes: number
shelf_layout: ReaderShelfLayout
shelf_grid_columns: number
shelf_sort: ReaderShelfSort
shelf_show_unread: boolean
shelf_show_update_time: boolean
}
/** 把本地状态整理成 /reader/profile 的载荷(只含参与同步的字段)。 */
export function readerSettingsPayload(s: ReaderSettingsState): ReaderSettingsProfile {
return {
theme_id: s.themeId,
night: s.night,
page_mode: s.pageMode,
font_size: s.fontSize,
line_height: s.lineHeight,
paragraph_spacing: s.paragraphSpacing,
audio_speed: s.audioSpeed,
audio_timer_minutes: s.audioTimerMinutes,
shelf_layout: s.shelfLayout,
shelf_grid_columns: s.shelfGridColumns,
shelf_sort: s.shelfSort,
shelf_show_unread: s.shelfShowUnread,
shelf_show_update_time: s.shelfShowUpdateTime,
}
}
/**
* 用服务端的偏好覆盖本地状态(服务端是权威来源)。
* 走 store 的 setter 而不是 setState,保证范围收敛只有一处实现。
*/
export function applyReaderSettingsProfile(p: ReaderSettingsProfile): void {
const s = useReaderSettingsStore.getState()
s.setThemeId(p.theme_id)
s.setNight(p.night)
s.setPageMode(p.page_mode)
s.setFontSize(p.font_size)
s.setLineHeight(p.line_height)
s.setParagraphSpacing(p.paragraph_spacing)
s.setAudioSpeed(p.audio_speed)
s.setAudioTimerMinutes(p.audio_timer_minutes)
s.setShelfLayout(p.shelf_layout)
s.setShelfGridColumns(p.shelf_grid_columns)
s.setShelfSort(p.shelf_sort)
s.setShelfShowUnread(p.shelf_show_unread)
s.setShelfShowUpdateTime(p.shelf_show_update_time)
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { isRenderableCover } from './coverUrl.ts'
import { isRenderableCover, needsCoverProxy } from './coverUrl.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`coverUrl: ${name}`)
@@ -27,4 +27,16 @@ check('source name is not renderable', !isRenderableCover('光遇聚合'))
check('bare relative path is not renderable', !isRenderableCover('files/a.jpg'))
check('javascript url is not renderable', !isRenderableCover('javascript:alert(1)'))
// needsCoverProxy:只有明文 http 的远端封面需要绕后端代理,否则 https 部署下
// 会被混合内容策略拦掉(书源图床酷我等没有 https,浏览器升级请求必然失败)。
check('http cover needs proxy', needsCoverProxy('http://img4.sycdn.kuwo.cn/star/albumcover/240/a.jpg'))
check('uppercase http cover needs proxy', needsCoverProxy('HTTP://img4.sycdn.kuwo.cn/a.jpg'))
check('padded http cover needs proxy', needsCoverProxy(' http://img4.sycdn.kuwo.cn/a.jpg '))
check('https cover needs no proxy', !needsCoverProxy('https://imagev2.xmcdn.com/a.jpg'))
check('protocol relative cover needs no proxy', !needsCoverProxy('//img.example.com/a.jpg'))
check('local asset path needs no proxy', !needsCoverProxy('/api/reader/local/asset?b=1&p=2&s=3'))
check('data uri needs no proxy', !needsCoverProxy('data:image/png;base64,iVBORw0KGgo='))
check('empty needs no proxy', !needsCoverProxy(''))
check('name that merely contains http needs no proxy', !needsCoverProxy('http 书籍'))
console.log('coverUrl.test.ts ok')
+13
View File
@@ -18,3 +18,16 @@ export function isRenderableCover(url?: string | null): boolean {
return false
}
// needsCoverProxy 判断封面是否必须绕道后端图片代理才能显示。
//
// 明文 http 的封面在 https 部署下会被浏览器当作「混合内容」:浏览器先把 http
// 自动升级成 https 再发请求,而不少书源图床(酷我 sycdn.kuwo.cn 等)根本没有
// https,升级失败后图片被直接丢弃,界面上只剩占位图标。同一份书源在本地 http
// 部署(127.0.0.1:8080)却一切正常,差异就出在这里。
//
// 其余地址不需要代理:https 本身是安全协议,data: 图片不经过网络,应用自身的
// 相对地址天然同源。代理它们只会白白多一次服务端抓取。
export function needsCoverProxy(url?: string | null): boolean {
return (url ?? '').trim().toLowerCase().startsWith('http://')
}
+99
View File
@@ -0,0 +1,99 @@
import {
ALL_GROUP_ID,
UNGROUPED_GROUP_ID,
attachBookToGroup,
buildBookGroupTabs,
dedupeBookGroups,
detachBooksFromGroup,
filterBooksByGroup,
groupIdOfBook,
normalizeBookGroups,
reorderBookGroups,
resolveSelectedGroupId,
} from './readerBookGroups'
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`readerBookGroups: ${name}`)
}
const book = (id: string) => ({ id })
// ── 清洗 ──
check('non-array → empty', normalizeBookGroups(null).length === 0)
check('blank name dropped', normalizeBookGroups([{ name: ' ', book_ids: ['a'] }]).length === 0)
check('name trimmed and ids deduped', (() => {
const got = normalizeBookGroups([{ name: ' 科幻 ', book_ids: ['a', '', 'a', 'b'] }])
return got.length === 1 && got[0].name === '科幻' && got[0].book_ids.join(',') === 'a,b'
})())
check('same name (case-insensitive) merged', (() => {
const got = normalizeBookGroups([
{ name: 'SciFi', book_ids: ['a'] },
{ name: 'scifi', book_ids: ['b'] },
])
return got.length === 1 && got[0].book_ids.join(',') === 'a,b'
})())
// ── 一书一组 ──
const claimed = dedupeBookGroups([
{ name: '一', book_ids: ['a', 'b'] },
{ name: '二', book_ids: ['b', 'c'] },
{ name: '三', book_ids: ['c'] },
])
check('dedupe keeps first claim', claimed[0].book_ids.join(',') === 'a,b')
// 二 原本 [b,c]:b 被 一 抢走后仍留住 c;三 的 [c] 已被 二 认领,因此变空
check('dedupe keeps the still-unclaimed member', claimed[1].book_ids.join(',') === 'c')
check('dedupe empties the group whose books were all claimed', claimed[2].book_ids.length === 0)
// ── 过滤 ──
const books = [book('a'), book('b'), book('c'), book('d')]
const groups = [{ name: '科幻', book_ids: ['a', 'b'] }, { name: '在读', book_ids: ['c'] }]
const ids = (list: { id: string }[]) => list.map((x) => x.id).join(',')
check('ALL returns everything', ids(filterBooksByGroup(books, groups, ALL_GROUP_ID)) === 'a,b,c,d')
check('empty id behaves like ALL', ids(filterBooksByGroup(books, groups, '')) === 'a,b,c,d')
check('group filter keeps members in input order', ids(filterBooksByGroup(books, groups, '科幻')) === 'a,b')
check('unknown group → empty', filterBooksByGroup(books, groups, '不存在').length === 0)
check('ungrouped returns unclaimed books', ids(filterBooksByGroup(books, groups, UNGROUPED_GROUP_ID)) === 'd')
check('stale ids in a group are ignored', (() => {
const withStale = [{ name: '科幻', book_ids: ['a', 'ghost'] }]
return ids(filterBooksByGroup(books, withStale, '科幻')) === 'a'
})())
// ── 分组栏 ──
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('全部 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('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
})())
// ── 排序与选中回落 ──
check(
'reorder follows the given order',
reorderBookGroups(groups, ['在读', '科幻']).map((g) => g.name).join(',') === '在读,科幻',
)
check(
'reorder appends unknown names (never drops)',
reorderBookGroups(groups, ['在读']).map((g) => g.name).join(',') === '在读,科幻',
)
check('selected group falls back to ALL when removed', resolveSelectedGroupId(groups, '已删除') === ALL_GROUP_ID)
check('selected group kept when it exists', resolveSelectedGroupId(groups, '科幻') === '科幻')
check('ungrouped selection is preserved', resolveSelectedGroupId(groups, UNGROUPED_GROUP_ID) === UNGROUPED_GROUP_ID)
// ── 归组 ──
const moved = attachBookToGroup(groups, 'c', '科幻')
check('attach moves the book out of its old group', moved[1].book_ids.length === 0)
check('attach appends to the target group', moved[0].book_ids.join(',') === 'a,b,c')
check('attach to unknown group is a no-op', attachBookToGroup(groups, 'c', '不存在') === groups)
check('detach returns the book to ungrouped', detachBooksFromGroup(groups, ['a'])[0].book_ids.join(',') === 'b')
check('groupIdOfBook finds the owner', groupIdOfBook(groups, 'c') === '在读' && groupIdOfBook(groups, 'd') === '')
console.log('readerBookGroups.test.ts ok')
+316
View File
@@ -0,0 +1,316 @@
import type { BookGroup } from '../api/reader'
// 书架分组的纯逻辑(对齐影视模块的 utils/libraryTags.ts)。
//
// 结构同构:组名 → 成员 ID 列表,「一本书只归一个组」,整份替换。
// 差别在于书架多两个内置页签:全部 / 未分组(未分组不持久化,只是「没归任何组」的视图)。
/** 与后端 model.MaxBookGroupNameLen 保持一致。 */
export const MAX_BOOK_GROUP_NAME_LENGTH = 24
/** 与后端 model.MaxBookGroups 保持一致。 */
export const MAX_BOOK_GROUPS = 50
/** 「全部」伪页签:不参与持久化,仅用于分组栏选中项。 */
export const ALL_GROUP_ID = '__all__'
/** 「未分组」伪页签:没有归入任何分组的书籍。 */
export const UNGROUPED_GROUP_ID = '__ungrouped__'
export type BookGroupTab = {
/** 选中的稳定标识:ALL_GROUP_ID / UNGROUPED_GROUP_ID / 分组名。 */
id: string
name: string
count: number
kind: 'all' | 'ungrouped' | 'group'
}
const STORAGE_KEY = 'mebox_reader_book_groups'
const SELECTED_KEY = 'mebox_reader_book_group_selected'
/** 分组名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeBookGroupName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_BOOK_GROUP_NAME_LENGTH).join('').trim()
}
export function normalizeBookGroups(raw: unknown): BookGroup[] {
if (!Array.isArray(raw)) return []
const out: BookGroup[] = []
const indexByName = new Map<string, number>()
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const name = normalizeBookGroupName(String((item as BookGroup).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_BOOK_GROUPS) break
out.push({ name, book_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (item as BookGroup).book_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].book_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].book_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时分组栏仍然可用。 */
export function readCachedBookGroups(): BookGroup[] {
if (typeof window === 'undefined') return []
try {
return normalizeBookGroups(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedBookGroups(groups: BookGroup[]): void {
if (typeof window === 'undefined') return
try {
if (groups.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedGroupId(): string {
if (typeof window === 'undefined') return ALL_GROUP_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_GROUP_ID
} catch {
return ALL_GROUP_ID
}
}
export function writeSelectedGroupId(groupId: string): void {
if (typeof window === 'undefined') return
try {
if (!groupId || groupId === ALL_GROUP_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, groupId)
} catch {
// 同上。
}
}
export async function loadBookGroups(): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const remote = normalizeBookGroups(await readerAPI.getBookGroups())
writeCachedBookGroups(remote)
return remote
}
export async function saveBookGroups(groups: BookGroup[]): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const saved = normalizeBookGroups(await readerAPI.setBookGroups(normalizeBookGroups(groups)))
writeCachedBookGroups(saved)
return saved
}
/**
* 收敛为「一本书只归一个组」:越靠前的分组优先,后面重复出现的书会被移除。
* 与后端 dedupeBookGroups 的语义一致。
*/
export function dedupeBookGroups(groups: BookGroup[]): BookGroup[] {
const claimed = new Set<string>()
return groups.map((group) => {
const bookIds: string[] = []
for (const id of group.book_ids) {
if (claimed.has(id)) continue
claimed.add(id)
bookIds.push(id)
}
return { ...group, book_ids: bookIds }
})
}
/** 分组名是否可用:非空、未超长、且(除自身外)没有重名。 */
export function bookGroupNameError(name: string, groups: BookGroup[], exceptName?: string): string {
const trimmed = normalizeBookGroupName(name)
if (!trimmed) return '分组名不能为空'
if (Array.from(name.trim()).length > MAX_BOOK_GROUP_NAME_LENGTH) {
return `分组名最多 ${MAX_BOOK_GROUP_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeBookGroupName(exceptName).toLowerCase() : ''
if (key !== selfKey && groups.some((group) => group.name.toLowerCase() === key)) {
return '分组名已存在'
}
return ''
}
export function findBookGroupIdByName(groups: BookGroup[], name: string): string {
const key = normalizeBookGroupName(name).toLowerCase()
if (!key) return ''
return groups.find((group) => group.name.toLowerCase() === key)?.name ?? ''
}
export function groupBookIds(groups: BookGroup[], groupId: string): string[] {
if (!groupId || groupId === ALL_GROUP_ID || groupId === UNGROUPED_GROUP_ID) return []
const group = groups.find((item) => item.name === groupId)
return group ? group.book_ids : []
}
/** 一本书所属的分组名;未归组返回空串。 */
export function groupIdOfBook(groups: BookGroup[], bookId: string): string {
for (const group of groups) {
if (group.book_ids.includes(bookId)) return group.name
}
return ''
}
export function isBookInGroup(groups: BookGroup[], groupId: string, bookId: string): boolean {
return groupBookIds(groups, groupId).includes(bookId)
}
/** 所有已被某个分组认领的书籍 ID。 */
export function groupedBookIds(groups: BookGroup[]): Set<string> {
const out = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) out.add(id)
}
return out
}
/**
* 按分组过滤书籍:`全部` 原样返回,`未分组` 返回没有被任何分组认领的书。
* 只做成员筛选、保持输入顺序;展示顺序由调用方决定。
*/
export function filterBooksByGroup<T extends { id: string }>(
books: T[],
groups: BookGroup[],
groupId: string,
): T[] {
if (!groupId || groupId === ALL_GROUP_ID) return books
if (groupId === UNGROUPED_GROUP_ID) {
const claimed = groupedBookIds(groups)
return books.filter((book) => !claimed.has(book.id))
}
const ids = new Set(groupBookIds(groups, groupId))
if (ids.size === 0) return []
return books.filter((book) => ids.has(book.id))
}
/**
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
const available = new Set(books.map((book) => book.id))
const claimed = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) {
if (available.has(id)) claimed.add(id)
}
}
const tabs: BookGroupTab[] = [
{ 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,
name: group.name,
count: group.book_ids.filter((id) => available.has(id)).length,
kind: 'group',
})
}
return tabs
}
/**
* 按给定分组名顺序重排:`orderedNames` 是期望顺序(通常来自拖拽结果)。
* 未出现在顺序里的分组保持原相对顺序并追加到末尾,绝不丢失。
*/
export function reorderBookGroups(groups: BookGroup[], orderedNames: string[]): BookGroup[] {
if (groups.length < 2) return groups
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: BookGroup[] = []
const rest: BookGroup[] = []
for (const group of groups) {
;(rank.has(group.name) ? ranked : rest).push(group)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的分组是否仍存在;被删除时回落到「全部」。 */
export function resolveSelectedGroupId(groups: BookGroup[], selectedId: string): string {
if (!selectedId || selectedId === ALL_GROUP_ID || selectedId === UNGROUPED_GROUP_ID) return selectedId || ALL_GROUP_ID
return groups.some((group) => group.name === selectedId) ? selectedId : ALL_GROUP_ID
}
/** 把一本书挂到分组下:先从原分组移除,保证一书一组。 */
export function attachBookToGroup(groups: BookGroup[], bookId: string, groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
if (!target) return groups
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => id !== bookId),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, bookId] } : group,
)
}
/** 批量把书籍挂到分组下;整批只产生一次写入。 */
export function attachBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
const moving = normalizeBookIds(bookIds)
if (!target || moving.length === 0) return groups
const movingSet = new Set(moving)
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, ...moving] } : group,
)
}
/** 批量把书籍移出所有分组(即变为「未分组」)。 */
export function detachBooksFromGroup(groups: BookGroup[], bookIds: string[]): BookGroup[] {
const moving = new Set(normalizeBookIds(bookIds))
if (moving.size === 0) return groups
return groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:groupName 为空表示移出所有分组。 */
export function assignBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
return groupName ? attachBooksToGroup(groups, bookIds, groupName) : detachBooksFromGroup(groups, bookIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeBookIds(bookIds: unknown): string[] {
if (!Array.isArray(bookIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of bookIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
+14
View File
@@ -0,0 +1,14 @@
import { imageURL } from '../api/client'
import { needsCoverProxy } from './coverUrl'
// readerCoverSrc 把书源给出的封面地址解析成 <img> 可以直接用的地址。
//
// 书源封面常挂在没有 https 的图床上(酷我 sycdn.kuwo.cn 等)。https 部署下直接
// 引用这类明文 http 地址会被浏览器的混合内容策略拦掉,封面退化成占位图标;改由
// 后端 /api/img 抓取后同源下发就没有这个问题。其余地址原样返回,不给本就能直连
// 的封面增加一次服务端中转。
export function readerCoverSrc(url?: string | null): string {
const value = (url ?? '').trim()
if (!value) return ''
return needsCoverProxy(value) ? imageURL(value) : value
}
+63
View File
@@ -0,0 +1,63 @@
import { readerAPI } from '../api/reader'
import { isAuthenticated } from '../stores/auth'
import {
applyReaderSettingsProfile,
readerSettingsPayload,
useReaderSettingsStore,
} from '../stores/readerSettings'
// 阅读器偏好(主题 / 排版 / 听书 / 书架展示)的跨设备同步。
//
// 服务端是权威来源,localStorage(zustand persist)退化成首屏缓存:
// - 登录后 hydrateReaderSettings() 拉一次服务端偏好覆盖本地;
// - 之后本地任何改动防抖回写服务端。
// 该用户还没保存过时服务端返回 null,这时把本地现值推上去「播种」,
// 这样升级前已经在本机调好的设置不会被重置成默认值。
/** 单次回写延迟:把连点字体 +/-、拖滑块这类连续操作合并成一次请求。 */
const PUSH_DEBOUNCE_MS = 600
/** 拉取服务端期间抑制回写,避免把本地旧值当成用户改动推上去。 */
let hydrating = false
let pushTimer: number | null = null
let subscribed = false
function schedulePush(): void {
if (hydrating || !isAuthenticated()) return
if (pushTimer !== null) window.clearTimeout(pushTimer)
pushTimer = window.setTimeout(() => {
pushTimer = null
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
readerAPI.saveReaderSettings(payload).catch(() => undefined)
}, PUSH_DEBOUNCE_MS)
}
/** 订阅本地偏好变化并防抖回写服务端;重复调用只生效一次。 */
export function startReaderSettingsSync(): void {
if (subscribed) return
subscribed = true
useReaderSettingsStore.subscribe(() => schedulePush())
}
/**
* 拉取当前账号的阅读器偏好并应用到本地。
* 服务端异常 / 离线时保持本地值不动,等下一次改动再同步。
*/
export async function hydrateReaderSettings(): Promise<void> {
if (!isAuthenticated()) return
hydrating = true
try {
const remote = await readerAPI.getReaderSettings()
if (remote) {
applyReaderSettingsProfile(remote)
} else {
// 该账号还没有偏好记录:用本地现值播种,保证老用户的设置迁移过去
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
await readerAPI.saveReaderSettings(payload).catch(() => undefined)
}
} catch {
// 保持本地缓存值,不打扰用户
} finally {
hydrating = false
}
}