From 9036ad04e79f70754768e19d91b8b400ec00c672 Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Thu, 1 Oct 2026 22:18:37 +0800
Subject: [PATCH 01/61] =?UTF-8?q?=E9=95=9C=E5=83=8F=E9=97=AE=E9=A2=98?=
=?UTF-8?q?=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/Auto-docker-publish.yml | 2 +-
.github/workflows/beta-build.yml | 59 +++++++++++++----------
.github/workflows/ci.yml | 2 +-
Dockerfile | 4 +-
README.md | 2 +-
README_EN.md | 2 +-
6 files changed, 39 insertions(+), 32 deletions(-)
diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml
index fd02333..cea7814 100644
--- a/.github/workflows/Auto-docker-publish.yml
+++ b/.github/workflows/Auto-docker-publish.yml
@@ -187,7 +187,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.26'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml
index 44c7208..937b20f 100644
--- a/.github/workflows/beta-build.yml
+++ b/.github/workflows/beta-build.yml
@@ -4,10 +4,12 @@
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
-# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
+# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
+# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
#
-# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
-# 不打 release tag,只构建带 -beta 标识的产物供测试。
+# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
+# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
+# 不打 release tag、不发布 GitHub Release、不部署服务器。
name: Beta Build
@@ -22,9 +24,6 @@ permissions:
contents: read
packages: write
-env:
- BETA_VERSION_PREFIX: beta
-
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
@@ -32,17 +31,24 @@ jobs:
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ binary_version: ${{ steps.version.outputs.binary_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ # 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
+ # beta 只读不自增,避免与 main 的发布流程争抢版本号。
- name: Resolve beta version
id: version
run: |
- BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
+ git fetch origin version || true
+ BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
- echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
+ echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
+ echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
@@ -59,7 +65,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.26'
cache: true
- name: go vet
@@ -71,13 +77,13 @@ jobs:
- name: go build (host)
run: go build ./...
- # 多平台可执行文件(嵌入刚构建的 web/dist)
+ # 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
- name: Build linux/amd64
- run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
+ run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
- run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
+ run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
- run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
+ run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -87,7 +93,8 @@ jobs:
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
- # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
+ # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
+ # 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
@@ -98,13 +105,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Resolve beta version
- id: version
- run: |
- BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
- SHA_SHORT=${GITHUB_SHA:0:7}
- echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
-
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -115,6 +115,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Extract image metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/mebox
+ tags: |
+ type=raw,value=beta
+ type=raw,value=${{ needs.test-and-build.outputs.version }}
+
- name: Build & push
uses: docker/build-push-action@v6
with:
@@ -123,11 +132,9 @@ jobs:
push: true
provenance: false
sbom: false
- tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
- labels: |
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.source=${{ github.repository }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
build-args: |
- VERSION=${{ steps.version.outputs.full_version }}
+ VERSION=${{ needs.test-and-build.outputs.binary_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e69d2e..e416885 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.26'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
diff --git a/Dockerfile b/Dockerfile
index 79edaff..1adce9b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
-# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
+# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
@@ -25,7 +25,7 @@ COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
-FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
+FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct
diff --git a/README.md b/README.md
index b48e5d1..6bb7cbc 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
-
+
diff --git a/README_EN.md b/README_EN.md
index 18ab276..8a828ec 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -21,7 +21,7 @@
-
+
From 6584519bd8d379146fc7e2bf719cb365ab6432c3 Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:29:49 +0800
Subject: [PATCH 02/61] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/handler/reader_routes.go | 24 ++
internal/service/reader/cover_url_test.go | 38 +++
internal/service/reader/reader.go | 120 +++++++++-
internal/service/reader/rule/analyzer.go | 25 +-
.../reader/rule/analyzer_url_fallback_test.go | 60 +++++
.../service/reader/rule/inner_rule_test.go | 58 +++++
internal/service/reader/search_merge_test.go | 73 ++++++
internal/service/reader/switch_origin_test.go | 219 ++++++++++++++++++
web/src/api/reader.ts | 6 +
web/src/components/LayoutHeaderSections.tsx | 17 +-
web/src/components/ReaderBookCover.tsx | 46 ++++
web/src/pages/reader/ReaderBookPage.tsx | 99 +++++++-
web/src/pages/reader/ReaderHomeContent.tsx | 15 +-
web/src/pages/reader/ReaderSearchPage.tsx | 78 ++++---
web/src/pages/reader/ReaderViewPage.tsx | 87 ++++++-
web/src/pages/reader/SourcePickerDialog.tsx | 105 +++++++++
web/src/utils/coverUrl.test.ts | 30 +++
web/src/utils/coverUrl.ts | 20 ++
web/src/utils/kindTags.test.ts | 20 ++
web/src/utils/kindTags.ts | 11 +
20 files changed, 1072 insertions(+), 79 deletions(-)
create mode 100644 internal/service/reader/cover_url_test.go
create mode 100644 internal/service/reader/rule/analyzer_url_fallback_test.go
create mode 100644 internal/service/reader/rule/inner_rule_test.go
create mode 100644 internal/service/reader/search_merge_test.go
create mode 100644 internal/service/reader/switch_origin_test.go
create mode 100644 web/src/components/ReaderBookCover.tsx
create mode 100644 web/src/pages/reader/SourcePickerDialog.tsx
create mode 100644 web/src/utils/coverUrl.test.ts
create mode 100644 web/src/utils/coverUrl.ts
create mode 100644 web/src/utils/kindTags.test.ts
create mode 100644 web/src/utils/kindTags.ts
diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go
index 17132df..23cdee7 100644
--- a/internal/handler/reader_routes.go
+++ b/internal/handler/reader_routes.go
@@ -57,6 +57,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
+ // 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
+ g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
@@ -414,6 +416,28 @@ func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
}
}
+// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
+// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
+func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
+ var body struct {
+ Origin reader.SearchOrigin `json:"origin" binding:"required"`
+ }
+ return func(c *gin.Context) {
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ userID := c.GetString(middleware.CtxUserID)
+ book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
+ c.JSON(http.StatusOK, book)
+ }
+}
+
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
diff --git a/internal/service/reader/cover_url_test.go b/internal/service/reader/cover_url_test.go
new file mode 100644
index 0000000..a76a90b
--- /dev/null
+++ b/internal/service/reader/cover_url_test.go
@@ -0,0 +1,38 @@
+package reader
+
+import "testing"
+
+// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回,
+// normalizeCoverURL 必须拦住这类非图片地址,避免前端
显示破图。
+func TestNormalizeCoverURL(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty", "", ""},
+ {"blank", " ", ""},
+ {"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
+ {"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
+ {"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
+ {"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
+ {"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
+ {"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
+ {
+ "search params envelope",
+ "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
+ "",
+ },
+ {"text data uri", "data:text/plain;base64,aGk=", ""},
+ {"source name", "光遇聚合", ""},
+ {"javascript url", "javascript:alert(1)", ""},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := normalizeCoverURL(tc.in); got != tc.want {
+ t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index e1e134f..ba38f0e 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -718,6 +718,9 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook {
k := b.Name + "|" + b.Author
if existing, ok := merged[k]; ok {
existing.Origins = append(existing.Origins, b.Origins...)
+ // 同书多源时补齐缺失字段:legado 是「一源一行」,各源自己显示拿到的
+ // 信息;MeBox 合并成一行,若不补齐,先到的空值会挡掉后面源的有效值。
+ fillMissingSearchBookFields(existing, &b)
continue
}
merged[k] = &b
@@ -739,6 +742,29 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook {
return out
}
+// fillMissingSearchBookFields 把同书多源结果里另一份的有效字段补进合并结果。
+//
+// 同一个聚合源的不同上游、或不同书源,对同一本书的覆盖能力不同:有的能给封面、
+// 简介、字数、最新章节,有的只给书名。合并成一行时必须补齐,否则先到的空值会把
+// 后面源的有效值挡掉——典型表现就是「这本书明明有源带封面,列表里却是空白」。
+func fillMissingSearchBookFields(dst, src *SearchBook) {
+ if dst.CoverURL == "" {
+ dst.CoverURL = src.CoverURL
+ }
+ if dst.Intro == "" {
+ dst.Intro = src.Intro
+ }
+ if dst.Kind == "" {
+ dst.Kind = src.Kind
+ }
+ if dst.WordCount == "" {
+ dst.WordCount = src.WordCount
+ }
+ if dst.LatestChapter == "" {
+ dst.LatestChapter = src.LatestChapter
+ }
+}
+
// searchInSource 单源搜索(对应 WebBook.searchBook)。
func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) ([]SearchBook, error) {
if bs == nil {
@@ -800,7 +826,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
WordCount: wordCount,
LatestChapter: lastChapter,
Intro: intro,
- CoverURL: cover,
+ CoverURL: normalizeCoverURL(cover),
BookURL: bookURL,
Origins: []SearchOrigin{{
SourceID: src.ID,
@@ -835,6 +861,33 @@ func firstNonEmpty(vals ...string) string {
return ""
}
+// normalizeCoverURL 过滤书源规则给出的封面地址,只保留浏览器能真正渲染成图片的
+// 值,其余一律返回空串(前端据此显示占位图标,而不是破图)。
+//
+// 背景:书源在「这本书没有封面」时不一定返回空值。legado 语义下
+// getString(rule, isUrl=true) 取值为空会回退成 baseUrl,而聚合类书源
+// (如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封,
+// 于是封面上会落一串 data: 文本,
按 text/plain 处理必然破图。
+// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
+// (/api/reader/local/asset?...)。
+func normalizeCoverURL(raw string) string {
+ u := strings.TrimSpace(raw)
+ if u == "" {
+ return ""
+ }
+ lower := strings.ToLower(u)
+ switch {
+ case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
+ return u
+ case strings.HasPrefix(lower, "data:image/"):
+ return u
+ case strings.HasPrefix(u, "/"):
+ return u
+ default:
+ return ""
+ }
+}
+
// ─── 详情 / 目录 / 正文 ─────────────────────────────────────────────────────
// BookInfo 书籍详情(对应 legado Book 信息页)。
@@ -912,7 +965,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
info.Intro = v
}
if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" {
- info.CoverURL = v
+ info.CoverURL = normalizeCoverURL(v)
}
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
info.TocURL = v
@@ -1300,7 +1353,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
BookURL: origin.BookURL,
Name: name,
Author: author,
- CoverURL: coverURL,
+ CoverURL: normalizeCoverURL(coverURL),
Type: origin.OriginType,
}
if err := s.repo.CreateBook(ctx, book); err != nil {
@@ -1309,6 +1362,67 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
return book, nil
}
+// SwitchOrigin 换源:把书架里的书切到另一个书源(对应 legado 的「换源」)。
+//
+// 换源要同时处理三件事:
+// 1. 来源字段整体换成新源(origin / origin_name / book_url / toc_url);
+// 2. 旧源缓存的目录必须清掉——章节地址只对旧源有效,留着会让阅读器读到错内容;
+// 3. 阅读进度保留(按章节序号定位,与 legado 的做法一致)。
+//
+// 新源详情是「尽力而为」:抓得到就用它的 tocUrl(以及当前封面为空时的封面),
+// 抓不到就把 tocUrl 留空让目录回落到书籍地址重新解析,不让一次网络抖动挡住换源。
+func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string, origin SearchOrigin) (*model.ReaderBook, error) {
+ book, err := s.repo.GetBook(ctx, bookID)
+ if err != nil {
+ return nil, err
+ }
+ if book.UserID != userID {
+ return nil, fmt.Errorf("无权操作他人书架")
+ }
+ if book.LocalPath != "" {
+ return nil, fmt.Errorf("本地导入的书籍没有书源,无法换源")
+ }
+ target := strings.TrimSpace(origin.BookURL)
+ if target == "" {
+ return nil, fmt.Errorf("缺少目标书源的书本地址")
+ }
+ if _, _, err := s.loadSourceFlexible(ctx, origin.SourceID, origin.Origin); err != nil {
+ return nil, err
+ }
+ if book.BookURL == target && book.Origin == origin.Origin {
+ return book, nil // 已经是这个源,重复点击视为成功
+ }
+
+ info, infoErr := s.GetBookInfo(ctx, origin.SourceID, origin.Origin, target)
+ if infoErr != nil && s.log != nil {
+ s.log.Warn("reader: 换源时读取新源详情失败",
+ zap.String("book", book.ID), zap.String("origin", origin.Origin), zap.Error(infoErr))
+ }
+
+ book.Origin = origin.Origin
+ book.OriginName = firstNonEmpty(origin.OriginName, book.OriginName)
+ book.BookURL = target
+ book.TocURL = ""
+ if info != nil {
+ book.TocURL = strings.TrimSpace(info.TocURL)
+ if book.CoverURL == "" {
+ book.CoverURL = normalizeCoverURL(info.CoverURL)
+ }
+ if info.LatestChapter != "" {
+ book.LatestChapterTitle = info.LatestChapter
+ }
+ }
+ // 目录是旧源的缓存,必须清空;章节数一并归零,等新源目录重新预热后再算未读。
+ book.TotalChapterNum = 0
+ if err := s.repo.ReplaceChapters(ctx, book.ID, nil); err != nil {
+ return nil, err
+ }
+ if err := s.repo.UpdateBook(ctx, book); err != nil {
+ return nil, err
+ }
+ return book, nil
+}
+
// GetBook 按 ID 取书(媒体代理等使用)。
func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
return s.repo.GetBook(ctx, id)
diff --git a/internal/service/reader/rule/analyzer.go b/internal/service/reader/rule/analyzer.go
index 20d1111..5c22694 100644
--- a/internal/service/reader/rule/analyzer.go
+++ b/internal/service/reader/rule/analyzer.go
@@ -248,10 +248,16 @@ func (a *AnalyzeRule) putRule(putMap map[string]string) error {
return nil
}
-func (a *AnalyzeRule) makeDeps() *RuleDeps {
+// makeDeps 构造 MakeUpRule 展开内嵌 {{...}} 需要的执行环境。
+//
+// content 是当前正在解析的元素。legado 的内嵌规则({{$.x}} / {{@x}})在**当前元素**
+// 上求值:书源普遍用 `{{$.status}},{{$.score}}` 这种模板拼 kind、用
+// `{{$.source}} {{$.last_chapter_title}}` 拼最新章节。若拿整份响应去求值,
+// 这些模板会全部取空——表现为 kind=",,,"、"最新"为空(聚合源尤其明显)。
+func (a *AnalyzeRule) makeDeps(content any) *RuleDeps {
return &RuleDeps{
JS: a.evalJS,
- Rule: func(rule string) (string, error) { return a.GetString(rule, nil, false) },
+ Rule: func(rule string) (string, error) { return a.GetString(rule, content, false) },
Get: a.Get,
}
}
@@ -279,7 +285,7 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
- resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
+ resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return nil, err
}
@@ -378,7 +384,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
if err := a.putRule(sourceRule.putMap); err != nil {
return "", err
}
- resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
+ resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return "", err
}
@@ -423,7 +429,14 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
}
if isUrl {
if strings.TrimSpace(str) == "" {
- return a.baseUrl, nil
+ // 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
+ // 但 baseUrl 未必是地址:聚合类书源(如「光遇聚合」)的搜索请求地址
+ // 本身就是 data:;base64,... 参数信封,直接回退会把信封当成封面/书址
+ // 返回,前端
只能显示破图。故仅在 baseUrl 是 http(s) 地址时回退。
+ if base := strings.TrimSpace(a.baseUrl); isAbsURL(base) {
+ return base, nil
+ }
+ return "", nil
}
return a.absolutize(str), nil
}
@@ -444,7 +457,7 @@ func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) {
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
- resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
+ resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return nil, err
}
diff --git a/internal/service/reader/rule/analyzer_url_fallback_test.go b/internal/service/reader/rule/analyzer_url_fallback_test.go
new file mode 100644
index 0000000..4a028fc
--- /dev/null
+++ b/internal/service/reader/rule/analyzer_url_fallback_test.go
@@ -0,0 +1,60 @@
+package rule
+
+import "testing"
+
+// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。
+// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址,
+// 回退结果会把封面/书址污染成非图片文本(前端
直接破图)。
+func TestGetStringURLFallbackSkipsNonHTTPBaseURL(t *testing.T) {
+ const payload = `{"data":[{"book_name":"全球高武","author":"老鹰吃小鸡"}]}`
+ const envelope = "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ=="
+
+ elementOf := func(t *testing.T, content, baseURL string) (*AnalyzeRule, any) {
+ t.Helper()
+ a := NewAnalyzeRule()
+ a.SetContent(content, baseURL)
+ els, err := a.GetElements("$.data")
+ if err != nil {
+ t.Fatalf("GetElements: %v", err)
+ }
+ if len(els) != 1 {
+ t.Fatalf("GetElements: want 1 element, got %d", len(els))
+ }
+ return a, els[0]
+ }
+
+ t.Run("空取值不会把 data: 信封当地址返回", func(t *testing.T) {
+ a, el := elementOf(t, payload, envelope)
+ got, err := a.GetString("$.thumb_url", el, true)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != "" {
+ t.Fatalf("封面应为空,实际 = %q", got)
+ }
+ })
+
+ t.Run("baseUrl 是 http(s) 时仍按 legado 语义回退", func(t *testing.T) {
+ const base = "https://www.example.com/search?q=x"
+ a, el := elementOf(t, payload, base)
+ got, err := a.GetString("$.thumb_url", el, true)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != base {
+ t.Fatalf("want base fallback %q, got %q", base, got)
+ }
+ })
+
+ t.Run("规则有取值时原样返回", func(t *testing.T) {
+ const withCover = `{"data":[{"book_name":"书","thumb_url":"https://img.example.com/c.jpg"}]}`
+ a, el := elementOf(t, withCover, envelope)
+ got, err := a.GetString("$.thumb_url", el, true)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != "https://img.example.com/c.jpg" {
+ t.Fatalf("封面 = %q", got)
+ }
+ })
+}
diff --git a/internal/service/reader/rule/inner_rule_test.go b/internal/service/reader/rule/inner_rule_test.go
new file mode 100644
index 0000000..d00e32c
--- /dev/null
+++ b/internal/service/reader/rule/inner_rule_test.go
@@ -0,0 +1,58 @@
+package rule
+
+import "testing"
+
+// 书源普遍用 legado 的内嵌模板拼字段(聚合源的 kind / 最新章节就是这种写法):
+//
+// kind = "{{$.status}},{{$.score}},{{$.tags}}"
+// lastChapter = "{{$.source}} {{$.last_chapter_title}}"
+//
+// 模板必须在**当前元素**上求值。修复前这里拿整份响应求值,模板一律取空,
+// 表现为 kind=",,,"、最新章节为空——搜索结果和换源列表里所有条目长得一模一样。
+func TestInnerRuleTemplateEvaluatesAgainstElement(t *testing.T) {
+ const body = `{"data":[{"book_name":"全球高武","status":"连载","score":"9.2","tags":"都市","source":"svip_酷我","last_chapter_title":"第694章"}]}`
+ a := NewAnalyzeRule()
+ a.SetContent(body, "")
+ els, err := a.GetElements("$.data")
+ if err != nil {
+ t.Fatalf("GetElements: %v", err)
+ }
+ if len(els) != 1 {
+ t.Fatalf("want 1 element, got %d", len(els))
+ }
+ el := els[0]
+
+ got, err := a.GetString("{{$.status}},{{$.score}},{{$.tags}}", el, false)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != "连载,9.2,都市" {
+ t.Fatalf("kind 模板 = %q, want 连载,9.2,都市", got)
+ }
+
+ got, err = a.GetString("{{$.source}} {{$.last_chapter_title}}", el, false)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != "svip_酷我 第694章" {
+ t.Fatalf("最新章节模板 = %q, want svip_酷我 第694章", got)
+ }
+
+ // 列表规则(GetStringList)走的是同一套 MakeUpRule,必须同样生效
+ list, err := a.GetStringList("{{$.status}},{{$.score}}", el, false)
+ if err != nil {
+ t.Fatalf("GetStringList: %v", err)
+ }
+ if len(list) != 1 || list[0] != "连载,9.2" {
+ t.Fatalf("列表模板 = %v, want [连载,9.2]", list)
+ }
+
+ // 模板里取不存在的字段应得到空串,而不是报错
+ got, err = a.GetString("[{{$.not_exist}}]", el, false)
+ if err != nil {
+ t.Fatalf("GetString: %v", err)
+ }
+ if got != "[]" {
+ t.Fatalf("缺失字段 = %q, want []", got)
+ }
+}
diff --git a/internal/service/reader/search_merge_test.go b/internal/service/reader/search_merge_test.go
new file mode 100644
index 0000000..d60bf16
--- /dev/null
+++ b/internal/service/reader/search_merge_test.go
@@ -0,0 +1,73 @@
+package reader
+
+import "testing"
+
+// 同书多源合并:先到的源没有封面/简介时,必须用后面源的有效值补齐。
+// 否则「有源带封面」的书在合并成一行后会是空白(legado 一源一行则不会出现)。
+func TestMergeSearchResultsFillsMissingFields(t *testing.T) {
+ hits := []searchHit{
+ {book: SearchBook{
+ Name: "全球高武",
+ Author: "蛮荒帝霸",
+ CoverURL: "",
+ Origins: []SearchOrigin{{OriginName: "搜书神器", BookURL: "/a"}},
+ }},
+ {book: SearchBook{
+ Name: "全球高武",
+ Author: "蛮荒帝霸",
+ CoverURL: "https://res.chuangke.tv/qk/246/a3/76/2271.jpg",
+ Intro: "一句话简介",
+ Kind: "连载",
+ WordCount: "177.2万字",
+ LatestChapter: "第694章",
+ Origins: []SearchOrigin{{OriginName: "svip_AU文学", BookURL: "/b"}},
+ }},
+ }
+
+ got := mergeSearchResults(hits, "全球高武")
+ if len(got) != 1 {
+ t.Fatalf("合并后应只有 1 条,实际 %d", len(got))
+ }
+ book := got[0]
+ if book.CoverURL != "https://res.chuangke.tv/qk/246/a3/76/2271.jpg" {
+ t.Fatalf("封面未从其它源补齐: %q", book.CoverURL)
+ }
+ if book.Intro != "一句话简介" {
+ t.Fatalf("简介未补齐: %q", book.Intro)
+ }
+ if book.Kind != "连载" || book.WordCount != "177.2万字" || book.LatestChapter != "第694章" {
+ t.Fatalf("字段未补齐: %+v", book)
+ }
+ if len(book.Origins) != 2 {
+ t.Fatalf("源数 = %d, want 2", len(book.Origins))
+ }
+}
+
+// 已经有值的字段不能被后来的源覆盖(先到的更权威,且保证结果稳定)。
+func TestMergeSearchResultsKeepsExistingFields(t *testing.T) {
+ hits := []searchHit{
+ {book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://a.example.com/1.jpg", Intro: "甲的简介"}},
+ {book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://b.example.com/2.jpg", Intro: "乙的简介"}},
+ }
+
+ got := mergeSearchResults(hits, "书")
+ if len(got) != 1 {
+ t.Fatalf("合并后应只有 1 条,实际 %d", len(got))
+ }
+ if got[0].CoverURL != "https://a.example.com/1.jpg" || got[0].Intro != "甲的简介" {
+ t.Fatalf("不该被后来的源覆盖: %+v", got[0])
+ }
+}
+
+// 无书名的结果直接丢弃,且不会参与合并。
+func TestMergeSearchResultsSkipsEmptyName(t *testing.T) {
+ hits := []searchHit{
+ {book: SearchBook{Name: "", Author: "作者", CoverURL: "https://a.example.com/1.jpg"}},
+ {book: SearchBook{Name: "书", Author: "作者"}},
+ }
+
+ got := mergeSearchResults(hits, "书")
+ if len(got) != 1 || got[0].Name != "书" {
+ t.Fatalf("结果 = %+v", got)
+ }
+}
diff --git a/internal/service/reader/switch_origin_test.go b/internal/service/reader/switch_origin_test.go
new file mode 100644
index 0000000..bddc5bd
--- /dev/null
+++ b/internal/service/reader/switch_origin_test.go
@@ -0,0 +1,219 @@
+package reader
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/truewhile/MeBox/internal/model"
+)
+
+// importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。
+func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string {
+ t.Helper()
+ if _, err := svc.ImportSources(t.Context(), raw); err != nil {
+ t.Fatalf("导入书源失败: %v", err)
+ }
+ srcs, err := svc.ListSources(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, s := range srcs {
+ if s.SourceURL == sourceURL {
+ return s.ID
+ }
+ }
+ t.Fatalf("导入后找不到书源 %s", sourceURL)
+ return ""
+}
+
+// 换源:来源字段整体换到新源,旧源目录缓存清空,阅读进度保留。
+func TestSwitchOriginKeepsProgressAndClearsChapters(t *testing.T) {
+ oldSrv := e2eServer()
+ defer oldSrv.Close()
+ newSrv := e2eServer()
+ defer newSrv.Close()
+
+ svc, repos := newLoginTestService(t)
+ ctx := t.Context()
+ oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL)
+ newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL)
+
+ book, err := svc.AddBook(ctx, "u1", SearchOrigin{
+ SourceID: oldID,
+ Origin: oldSrv.URL,
+ OriginName: "旧源",
+ BookURL: oldSrv.URL + "/book/1",
+ }, "斗破苍穹", "天蚕土豆", "")
+ if err != nil {
+ t.Fatalf("加入书架失败: %v", err)
+ }
+
+ // 旧源目录缓存 + 阅读进度
+ chapters := make([]ChapterInput, 0, 5)
+ for i := 0; i < 5; i++ {
+ chapters = append(chapters, ChapterInput{
+ Index: i,
+ Title: fmt.Sprintf("第 %d 章", i+1),
+ URL: fmt.Sprintf("%s/book/1/c%d.html", oldSrv.URL, i),
+ })
+ }
+ if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
+ t.Fatalf("保存目录失败: %v", err)
+ }
+ if err := svc.SaveProgress(ctx, "u1", book.ID, 3, 120, "第 4 章"); err != nil {
+ t.Fatalf("保存进度失败: %v", err)
+ }
+
+ switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{
+ SourceID: newID,
+ Origin: newSrv.URL,
+ OriginName: "新源",
+ BookURL: newSrv.URL + "/book/1",
+ })
+ if err != nil {
+ t.Fatalf("换源失败: %v", err)
+ }
+
+ if switched.Origin != newSrv.URL || switched.OriginName != "新源" {
+ t.Fatalf("来源未切换: origin=%q name=%q", switched.Origin, switched.OriginName)
+ }
+ if switched.BookURL != newSrv.URL+"/book/1" {
+ t.Fatalf("书本地址未切换: %q", switched.BookURL)
+ }
+ if !strings.HasPrefix(switched.TocURL, newSrv.URL) {
+ t.Fatalf("tocUrl 应指向新源: %q", switched.TocURL)
+ }
+ if switched.TotalChapterNum != 0 {
+ t.Fatalf("旧源章数应归零,实际 %d", switched.TotalChapterNum)
+ }
+ if switched.DurChapterIndex != 3 || switched.DurChapterPos != 120 || switched.DurChapterTitle != "第 4 章" {
+ t.Fatalf("阅读进度没保留: %+v", switched)
+ }
+
+ // 旧源章节缓存必须清掉(章节地址只对旧源有效)
+ left, err := repos.Reader.ListChapters(ctx, book.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != 0 {
+ t.Fatalf("换源后仍残留 %d 条旧源章节", len(left))
+ }
+
+ // 落库确认,避免只改了内存对象
+ stored, err := repos.Reader.GetBook(ctx, book.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Origin != newSrv.URL || stored.BookURL != newSrv.URL+"/book/1" || stored.DurChapterIndex != 3 {
+ t.Fatalf("换源结果未落库: %+v", stored)
+ }
+}
+
+// 空封面时用新源的封面补上;已有封面不被覆盖。
+func TestSwitchOriginFillsOnlyEmptyCover(t *testing.T) {
+ oldSrv := e2eServer()
+ defer oldSrv.Close()
+ newSrv := e2eServer()
+ defer newSrv.Close()
+
+ svc, repos := newLoginTestService(t)
+ ctx := t.Context()
+ oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL)
+ newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL)
+
+ book, err := svc.AddBook(ctx, "u1", SearchOrigin{
+ SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1",
+ }, "斗破苍穹", "天蚕土豆", "https://old.example.com/cover.jpg")
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := SearchOrigin{SourceID: newID, Origin: newSrv.URL, OriginName: "新源", BookURL: newSrv.URL + "/book/1"}
+
+ switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, target)
+ if err != nil {
+ t.Fatalf("换源失败: %v", err)
+ }
+ if switched.CoverURL != "https://old.example.com/cover.jpg" {
+ t.Fatalf("已有封面不该被覆盖: %q", switched.CoverURL)
+ }
+
+ // 清空封面后换源,应补上新源的封面(e2e 详情页没配封面规则,这里改为一本空封面的书)
+ switched.CoverURL = ""
+ if err := repos.Reader.UpdateBook(ctx, switched); err != nil {
+ t.Fatal(err)
+ }
+ back, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{
+ SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1",
+ })
+ if err != nil {
+ t.Fatalf("换回旧源失败: %v", err)
+ }
+ // e2e 书源没有 coverUrl 规则,所以这里只断言流程走通且不报错
+ if back.Origin != oldSrv.URL {
+ t.Fatalf("换回失败: %+v", back)
+ }
+}
+
+// 换源的各种拒绝场景。
+func TestSwitchOriginRejects(t *testing.T) {
+ srv := e2eServer()
+ defer srv.Close()
+
+ svc, repos := newLoginTestService(t)
+ ctx := t.Context()
+ srcID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
+
+ book, err := svc.AddBook(ctx, "u1", SearchOrigin{
+ SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1",
+ }, "斗破苍穹", "天蚕土豆", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ valid := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/2"}
+
+ t.Run("他人书架", func(t *testing.T) {
+ if _, err := svc.SwitchOrigin(ctx, "u2", book.ID, valid); err == nil || !strings.Contains(err.Error(), "无权") {
+ t.Fatalf("err = %v, want 无权操作他人书架", err)
+ }
+ })
+
+ t.Run("本地书没有书源", func(t *testing.T) {
+ local := &model.ReaderBook{
+ UserID: "u3",
+ Name: "本地书",
+ LocalPath: "abc.txt",
+ BookURL: "local://abc",
+ }
+ if err := repos.Reader.CreateBook(ctx, local); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.SwitchOrigin(ctx, "u3", local.ID, valid); err == nil || !strings.Contains(err.Error(), "本地导入") {
+ t.Fatalf("err = %v, want 本地书不可换源", err)
+ }
+ })
+
+ t.Run("缺少目标书本地址", func(t *testing.T) {
+ if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{Origin: srv.URL}); err == nil {
+ t.Fatal("缺少 book_url 时应报错")
+ }
+ })
+
+ t.Run("书源不存在", func(t *testing.T) {
+ bad := SearchOrigin{Origin: "https://nope.example.com", BookURL: "https://nope.example.com/book/1"}
+ if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, bad); err == nil {
+ t.Fatal("书源不存在时应报错")
+ }
+ })
+
+ t.Run("同一源重复换源视为成功", func(t *testing.T) {
+ same := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1"}
+ got, err := svc.SwitchOrigin(ctx, "u1", book.ID, same)
+ if err != nil {
+ t.Fatalf("重复换源不该报错: %v", err)
+ }
+ if got.BookURL != srv.URL+"/book/1" {
+ t.Fatalf("书本地址被改动: %q", got.BookURL)
+ }
+ })
+}
diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts
index cf7cf51..76eadce 100644
--- a/web/src/api/reader.ts
+++ b/web/src/api/reader.ts
@@ -283,6 +283,12 @@ export const readerAPI = {
.post('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
+ /**
+ * 换源:把书架里的书切到另一个书源。
+ * 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
+ */
+ switchOrigin: (id: string, origin: ReaderSearchOrigin) =>
+ api.post(`/reader/books/${id}/origin`, { origin }, longOpts).then((r) => r.data),
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
api.put(`/reader/books/${id}/progress`, body),
// 听书跳过片头/片尾(秒,0 不跳过)
diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx
index 8e2bfc6..8827e8e 100644
--- a/web/src/components/LayoutHeaderSections.tsx
+++ b/web/src/components/LayoutHeaderSections.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
-import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
+import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { ARTWORK, imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
@@ -12,6 +12,7 @@ import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import { LayoutReaderModeToggle } from './LayoutReaderModeToggle'
+import ReaderBookCover from './ReaderBookCover'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { ThemeMode, useThemeMode } from './useThemeMode'
@@ -248,19 +249,7 @@ function LayoutHeaderBookSearch() {
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
- {book.cover_url ? (
-

- ) : (
-
-
-
- )}
+
diff --git a/web/src/components/ReaderBookCover.tsx b/web/src/components/ReaderBookCover.tsx
new file mode 100644
index 0000000..1d7f792
--- /dev/null
+++ b/web/src/components/ReaderBookCover.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useState } from 'react'
+import { BookOpen } from 'lucide-react'
+
+import { isRenderableCover } from '../utils/coverUrl'
+
+// 阅读子系统统一封面图。
+//
+// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
+// 直接塞进
![]()
会显示破图。这里统一处理:地址不是图片、或加载失败时,
+// 一律回退成占位图标。
+export default function ReaderBookCover({
+ url,
+ alt = '',
+ iconSize = 18,
+ className = 'h-full w-full object-cover',
+}: {
+ url?: string | null
+ alt?: string
+ iconSize?: number
+ className?: string
+}) {
+ const [failed, setFailed] = useState(false)
+
+ useEffect(() => {
+ setFailed(false)
+ }, [url])
+
+ if (!isRenderableCover(url) || failed) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+

setFailed(true)}
+ />
+ )
+}
diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx
index c842d46..73889b2 100644
--- a/web/src/pages/reader/ReaderBookPage.tsx
+++ b/web/src/pages/reader/ReaderBookPage.tsx
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
-import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
+import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
-import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader'
+import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
+import ReaderBookCover from '../../components/ReaderBookCover'
+import { SourcePickerDialog } from './SourcePickerDialog'
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
@@ -24,6 +26,10 @@ export default function ReaderBookPage() {
const [busy, setBusy] = useState(false)
const [tocExpanded, setTocExpanded] = useState(false)
const [introExpanded, setIntroExpanded] = useState(false)
+ // 换源:候选源来自按书名重新搜索的结果
+ const [pickerOpen, setPickerOpen] = useState(false)
+ const [pickLoading, setPickLoading] = useState(false)
+ const [candidates, setCandidates] = useState
([])
const loadInfo = () => {
if (!bookURL) return
@@ -120,6 +126,66 @@ export default function ReaderBookPage() {
}
}
+ // ── 换源 ──
+
+ // 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。
+ const openSourcePicker = async () => {
+ const key = (info?.name || qName).trim()
+ if (!key) {
+ toast.error('缺少书名,无法换源')
+ return
+ }
+ setPickerOpen(true)
+ setPickLoading(true)
+ setCandidates([])
+ try {
+ const res = await readerAPI.search(key)
+ const list = res.books ?? []
+ const wantAuthor = (info?.author || qAuthor).trim()
+ const hit =
+ list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ??
+ list.find((b) => b.name === key) ??
+ null
+ setCandidates(hit?.origins ?? [])
+ } catch (e) {
+ toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败')
+ } finally {
+ setPickLoading(false)
+ }
+ }
+
+ const bookPath = (origin: ReaderSearchOrigin) =>
+ `/reader/book?${new URLSearchParams({
+ source_url: origin.origin,
+ book_url: origin.book_url,
+ name,
+ author,
+ cover_url: cover,
+ origin_id: origin.source_id,
+ origin_name: origin.origin_name,
+ }).toString()}`
+
+ // 已在书架:调用换源接口(服务端保留进度、清空旧源目录缓存);
+ // 未在书架:这本书还没绑定书源,直接按所选源打开详情页即可。
+ const applyOrigin = async (origin: ReaderSearchOrigin) => {
+ setPickerOpen(false)
+ if (!shelfBook) {
+ navigate(bookPath(origin))
+ return
+ }
+ setBusy(true)
+ try {
+ await readerAPI.switchOrigin(shelfBook.id, origin)
+ toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
+ // URL 上的旧源地址已失效,替换成新源地址,页面据此重新拉详情与目录
+ navigate(bookPath(origin), { replace: true })
+ } catch (e) {
+ toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
+ } finally {
+ setBusy(false)
+ }
+ }
+
if (!bookURL) {
return (
缺少书籍参数
@@ -151,13 +217,7 @@ export default function ReaderBookPage() {
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
- {cover ? (
-

- ) : (
-
-
-
- )}
+
{name || '未知书名'}
@@ -209,6 +269,15 @@ export default function ReaderBookPage() {
{busy ?
: null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
+
{/* 目录 */}
@@ -245,6 +314,18 @@ export default function ReaderBookPage() {
)}
+
+ {pickerOpen && (
+
setPickerOpen(false)}
+ />
+ )}
)
}
diff --git a/web/src/pages/reader/ReaderHomeContent.tsx b/web/src/pages/reader/ReaderHomeContent.tsx
index 3923efa..ed23f32 100644
--- a/web/src/pages/reader/ReaderHomeContent.tsx
+++ b/web/src/pages/reader/ReaderHomeContent.tsx
@@ -5,6 +5,7 @@ import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loade
import { readerAPI, type ReaderBook } from '../../api/reader'
import { confirmAction } from '../../components/confirmAction'
+import ReaderBookCover from '../../components/ReaderBookCover'
import { useAuthStore } from '../../stores/auth'
import { ReaderModeSwitch } from './ReaderModeSwitch'
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
@@ -272,19 +273,7 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
- {book.cover_url ? (
-

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

setFailed(true)}
/>
)
diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx
index 499a6ab..8880828 100644
--- a/web/src/components/RequireAuth.tsx
+++ b/web/src/components/RequireAuth.tsx
@@ -1,12 +1,25 @@
-import { ReactNode } from 'react'
+import { useEffect, type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
+import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
// Route guard: redirects to /login when no token is present.
export function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
+ const userId = useAuthStore((s) => s.user?.id ?? null)
const location = useLocation()
+
+ // 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
+ // 登录 / 换账号时重新拉取(这里同时覆盖影视外壳和 /reader/* 独立布局)。
+ useEffect(() => {
+ startReaderSettingsSync()
+ }, [])
+ useEffect(() => {
+ if (!userId) return
+ void hydrateReaderSettings()
+ }, [userId])
+
if (!token) {
return
}
diff --git a/web/src/hooks/useBookGroups.ts b/web/src/hooks/useBookGroups.ts
new file mode 100644
index 0000000..0e28931
--- /dev/null
+++ b/web/src/hooks/useBookGroups.ts
@@ -0,0 +1,260 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import toast from 'react-hot-toast'
+
+import type { BookGroup } from '../api/reader'
+import {
+ ALL_GROUP_ID,
+ assignBooksToGroup,
+ attachBookToGroup,
+ dedupeBookGroups,
+ detachBooksFromGroup,
+ loadBookGroups,
+ normalizeBookGroups,
+ normalizeBookGroupName,
+ readCachedBookGroups,
+ readSelectedGroupId,
+ reorderBookGroups,
+ resolveSelectedGroupId,
+ saveBookGroups,
+ writeCachedBookGroups,
+ writeSelectedGroupId,
+} from '../utils/readerBookGroups'
+
+export type UseBookGroupsResult = {
+ /** 当前用户维护的分组(顺序即分组栏顺序)。 */
+ groups: BookGroup[]
+ /** 分组数据是否仍在首次加载中。 */
+ loading: boolean
+ /** 是否有写入在途(可用于禁用重复点击)。 */
+ saving: boolean
+ /** 接口不可用(例如旧后端)时降级为本地分组。 */
+ loadError: boolean
+ /** 当前选中的分组栏:ALL_GROUP_ID 表示「全部」。 */
+ selectedGroupId: string
+ setSelectedGroupId: (groupId: string) => void
+ createGroup: (name: string) => Promise
+ renameGroup: (from: string, to: string) => Promise
+ /** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */
+ reorderGroups: (orderedNames: string[]) => Promise
+ removeGroup: (name: string) => Promise
+ /** 覆盖某个分组下的书籍集合(保持传入顺序)。 */
+ setGroupBooks: (name: string, bookIds: string[]) => Promise
+ /** 把一本书归到分组下;groupName 为空表示移出所有分组。 */
+ assignBook: (bookId: string, groupName: string) => Promise
+ /** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */
+ assignBooks: (bookIds: string[], groupName: string) => Promise
+}
+
+/**
+ * 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。
+ *
+ * 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`),
+ * 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的
+ * 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。
+ */
+export function useBookGroups(): UseBookGroupsResult {
+ const [groups, setGroups] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [loadError, setLoadError] = useState(false)
+ const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId())
+
+ const groupsRef = useRef([])
+ const selectedRef = useRef(selectedGroupId)
+ const savedRef = useRef([])
+ const chainRef = useRef>(Promise.resolve())
+ const pendingRef = useRef(0)
+ // 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
+ const localOnlyRef = useRef(false)
+
+ useEffect(() => {
+ groupsRef.current = groups
+ }, [groups])
+
+ useEffect(() => {
+ selectedRef.current = selectedGroupId
+ }, [selectedGroupId])
+
+ useEffect(() => {
+ let cancelled = false
+ setLoading(true)
+ loadBookGroups()
+ .then((rows) => {
+ if (cancelled) return
+ groupsRef.current = rows
+ savedRef.current = rows
+ setGroups(rows)
+ setLoadError(false)
+ })
+ .catch((err) => {
+ if (cancelled) return
+ // 旧后端没有 /reader/book-groups:用本地缓存兜底,分组栏照常可用。
+ const cached = readCachedBookGroups()
+ groupsRef.current = cached
+ savedRef.current = cached
+ setGroups(cached)
+ setLoadError(true)
+ localOnlyRef.current = isMissingEndpoint(err)
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ // 选中的分组可能被删除,落回「全部」以免分组栏空白。
+ // 首次加载完成前 groups 为空集合,此时不能据此判定分组已被删除。
+ useEffect(() => {
+ if (loading) return
+ const resolved = resolveSelectedGroupId(groups, selectedGroupId)
+ if (resolved !== selectedGroupId) {
+ selectedRef.current = resolved
+ setSelectedGroupIdState(resolved)
+ }
+ }, [loading, groups, selectedGroupId])
+
+ const setSelectedGroupId = useCallback((groupId: string) => {
+ const next = groupId && groupId !== ALL_GROUP_ID ? groupId : ALL_GROUP_ID
+ selectedRef.current = next
+ writeSelectedGroupId(next)
+ setSelectedGroupIdState(next)
+ }, [])
+
+ const mutate = useCallback((updater: (current: BookGroup[]) => BookGroup[]) => {
+ const rollback = savedRef.current
+ const next = dedupeBookGroups(normalizeBookGroups(updater(groupsRef.current)))
+ groupsRef.current = next
+ setGroups(next)
+
+ pendingRef.current += 1
+ setSaving(true)
+ const task = chainRef.current.then(async () => {
+ // 发送排队时的最新本地状态:链上更早的写入已被后面的状态取代。
+ const snapshot = groupsRef.current
+ if (localOnlyRef.current) {
+ if (groupsRef.current === snapshot) savedRef.current = snapshot
+ writeCachedBookGroups(snapshot)
+ return
+ }
+ try {
+ const saved = await saveBookGroups(snapshot)
+ savedRef.current = saved
+ setLoadError(false)
+ if (groupsRef.current === snapshot) {
+ groupsRef.current = saved
+ setGroups(saved)
+ }
+ } catch (err) {
+ if (isMissingEndpoint(err)) {
+ // 旧后端不支持分组接口:本次修改保留在本地存储,分组栏照常可用。
+ localOnlyRef.current = true
+ savedRef.current = snapshot
+ writeCachedBookGroups(snapshot)
+ setLoadError(true)
+ return
+ }
+ // 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的分组。
+ groupsRef.current = rollback
+ savedRef.current = rollback
+ setGroups(rollback)
+ setLoadError(true)
+ toast.error('分组保存失败,请稍后重试')
+ }
+ })
+ chainRef.current = task.catch(() => undefined)
+ return task.finally(() => {
+ pendingRef.current = Math.max(0, pendingRef.current - 1)
+ if (pendingRef.current === 0) setSaving(false)
+ })
+ }, [])
+
+ const createGroup = useCallback(
+ async (name: string) => {
+ const trimmed = normalizeBookGroupName(name)
+ if (!trimmed) return ''
+ if (groupsRef.current.some((group) => group.name.toLowerCase() === trimmed.toLowerCase())) {
+ return trimmed
+ }
+ await mutate((current) => [...current, { name: trimmed, book_ids: [] }])
+ return groupsRef.current.some((group) => group.name === trimmed) ? trimmed : ''
+ },
+ [mutate],
+ )
+
+ const renameGroup = useCallback(
+ async (from: string, to: string) => {
+ const trimmed = normalizeBookGroupName(to)
+ if (!trimmed || trimmed === from) return
+ await mutate((current) =>
+ current.map((group) => (group.name === from ? { ...group, name: trimmed } : group)),
+ )
+ // 重命名后选中项要跟着换到新名字,否则分组栏会落回「全部」。
+ if (selectedRef.current === from) setSelectedGroupId(trimmed)
+ },
+ [mutate, setSelectedGroupId],
+ )
+
+ const reorderGroups = useCallback(
+ async (orderedNames: string[]) => {
+ await mutate((current) => reorderBookGroups(current, orderedNames))
+ },
+ [mutate],
+ )
+
+ const removeGroup = useCallback(
+ async (name: string) => {
+ await mutate((current) => current.filter((group) => group.name !== name))
+ if (selectedRef.current === name) setSelectedGroupId(ALL_GROUP_ID)
+ },
+ [mutate, setSelectedGroupId],
+ )
+
+ const setGroupBooks = useCallback(
+ async (name: string, bookIds: string[]) => {
+ await mutate((current) =>
+ current.map((group) => (group.name === name ? { ...group, book_ids: bookIds } : group)),
+ )
+ },
+ [mutate],
+ )
+
+ const assignBook = useCallback(
+ async (bookId: string, groupName: string) => {
+ await mutate((current) =>
+ groupName ? attachBookToGroup(current, bookId, groupName) : detachBooksFromGroup(current, [bookId]),
+ )
+ },
+ [mutate],
+ )
+
+ const assignBooks = useCallback(
+ async (bookIds: string[], groupName: string) => {
+ await mutate((current) => assignBooksToGroup(current, bookIds, groupName))
+ },
+ [mutate],
+ )
+
+ return {
+ groups,
+ loading,
+ saving,
+ loadError,
+ selectedGroupId,
+ setSelectedGroupId,
+ createGroup,
+ renameGroup,
+ reorderGroups,
+ removeGroup,
+ setGroupBooks,
+ assignBook,
+ assignBooks,
+ }
+}
+
+/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
+function isMissingEndpoint(err: unknown): boolean {
+ const status = (err as { response?: { status?: number } })?.response?.status
+ return status === 404 || status === 405 || status === 501
+}
diff --git a/web/src/pages/reader/BookshelfCard.tsx b/web/src/pages/reader/BookshelfCard.tsx
new file mode 100644
index 0000000..217cd39
--- /dev/null
+++ b/web/src/pages/reader/BookshelfCard.tsx
@@ -0,0 +1,234 @@
+import { BookOpen, Clock, HardDrive, History, UserRound } from 'lucide-react'
+
+import type { ReaderBook } from '../../api/reader'
+import { BookCardMenu } from '../../components/BookCardMenu'
+import ReaderBookCover from '../../components/ReaderBookCover'
+import type { ReaderShelfLayout } from '../../stores/readerSettings'
+import { formatRelativeTime, readProgressText, shelfUpdateTime, unreadChapters } from './bookshelfModel'
+
+// 书架卡片:网格 / 列表 / 紧凑列表三种布局共用。
+//
+// 信息层级对齐 legado 书架物品:
+// 网格 —— 封面 + 本地角标 + 未读徽标 + 书名(两行)
+// 列表 —— 封面 + 书名 + 作者 + 读到 + 最新章节 + 更新时间 + 未读徽标
+// 紧凑 —— 小封面 + 书名 + 作者 · 读到
+// 点卡片进阅读器;「详情」进书籍详情页;「移出」二次确认由调用方处理。
+
+function UnreadBadge({ book, compact = false }: { book: ReaderBook; compact?: boolean }) {
+ const unread = unreadChapters(book)
+ if (unread === null) return null
+ return (
+ 0 ? `还有 ${unread} 章未读` : '已读完'}
+ className={`shrink-0 whitespace-nowrap rounded-md font-bold ${
+ compact ? 'px-1 py-px text-[10px]' : 'px-1.5 py-0.5 text-2xs'
+ } ${unread > 0 ? 'bg-rose-500/90 text-white' : 'bg-[var(--app-hover)] text-[var(--app-muted)]'}`}
+ >
+ {unread > 0 ? `${unread} 章未读` : '已读完'}
+
+ )
+}
+
+function LocalBadge() {
+ return (
+
+ 本地
+
+ )
+}
+
+function GridCard({
+ book,
+ showUnread,
+ onOpen,
+ onPickGroup,
+ onRemove,
+}: {
+ book: ReaderBook
+ showUnread: boolean
+ onOpen: () => void
+ onPickGroup: () => void
+ onRemove: () => void
+}) {
+ return (
+
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onOpen()
+ }
+ }}
+ className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
+ >
+
+
+
+ {/* 左下角:本地来源标记(右上角留给未读徽标,右下角是详情/移出按钮) */}
+ {book.is_local && (
+
+
+
+ )}
+ {showUnread && unreadChapters(book) !== null && (
+
+
+
+ )}
+
+
+
+
+
{book.name}
+
+ )
+}
+
+function ListCard({
+ book,
+ showUnread,
+ showUpdateTime,
+ groupName = '',
+ compact = false,
+ onOpen,
+ onPickGroup,
+ onRemove,
+}: {
+ book: ReaderBook
+ showUnread: boolean
+ showUpdateTime: boolean
+ /** 所属分组名;空串表示未分组。 */
+ groupName?: string
+ compact?: boolean
+ onOpen: () => void
+ onPickGroup: () => void
+ onRemove: () => void
+}) {
+ const read = readProgressText(book)
+ const updated = shelfUpdateTime(book)
+ const coverClass = compact ? 'h-16 w-12' : 'h-[90px] w-[66px]'
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onOpen()
+ }
+ }}
+ className={`group relative flex cursor-pointer gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-2.5 shadow-sm transition hover:border-brand-500/40 hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
+ compact ? 'items-center' : 'items-stretch'
+ }`}
+ >
+
+
+ {book.is_local && (
+
+
+
+ )}
+
+
+
+
+
{book.name}
+ {showUnread &&
}
+
+
+ {compact ? (
+
+ {book.author || '佚名'}
+ {read && (
+ <>
+ ·
+ 读到 {read}
+ >
+ )}
+
+ ) : (
+ <>
+
+
+ {book.author || '佚名'}
+ {groupName && (
+ <>
+ ·
+
+ {groupName}
+
+ >
+ )}
+ {showUpdateTime && updated > 0 && (
+ <>
+ ·
+
+ {formatRelativeTime(updated)}
+ >
+ )}
+
+
+
+ {read ? `读到 ${read}` : '尚未开始阅读'}
+
+
+
+
+ 最新 {book.latest_chapter_title || '未知'}
+
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+export function BookshelfCard({
+ book,
+ layout,
+ showUnread,
+ showUpdateTime,
+ groupName = '',
+ onOpen,
+ onPickGroup,
+ onRemove,
+}: {
+ book: ReaderBook
+ layout: ReaderShelfLayout
+ showUnread: boolean
+ showUpdateTime: boolean
+ /** 所属分组名;空串表示未分组(只在列表布局里展示)。 */
+ groupName?: string
+ onOpen: () => void
+ onPickGroup: () => void
+ onRemove: () => void
+}) {
+ if (layout === 'grid') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+}
diff --git a/web/src/pages/reader/BookshelfSettingsDialog.tsx b/web/src/pages/reader/BookshelfSettingsDialog.tsx
new file mode 100644
index 0000000..49cb2f2
--- /dev/null
+++ b/web/src/pages/reader/BookshelfSettingsDialog.tsx
@@ -0,0 +1,200 @@
+import { Check, X } from 'lucide-react'
+import type { ReactNode } from 'react'
+
+import {
+ SHELF_GRID_COLUMNS,
+ useReaderSettingsStore,
+} from '../../stores/readerSettings'
+import { SHELF_LAYOUT_OPTIONS, SHELF_SORT_OPTIONS, gridColumnsLabel } from './bookshelfModel'
+
+// 书架设置弹窗(对应 legado 的 dialog_bookshelf_config):
+// 布局(列表 / 紧凑列表 / 网格 + 列数)、排序、以及显示项开关。
+// 所有选项直接落到 readerSettings store,关掉弹窗即生效。
+
+function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
+ return (
+
+
{title}
+ {hint &&
{hint}
}
+
{children}
+
+ )
+}
+
+function RadioOption({
+ active,
+ label,
+ hint,
+ onClick,
+}: {
+ active: boolean
+ label: string
+ hint?: string
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function ToggleOption({
+ active,
+ label,
+ hint,
+ onToggle,
+}: {
+ active: boolean
+ label: string
+ hint?: string
+ onToggle: () => void
+}) {
+ return (
+
+ )
+}
+
+export function BookshelfSettingsDialog({ onClose }: { onClose: () => void }) {
+ const layout = useReaderSettingsStore((s) => s.shelfLayout)
+ const setLayout = useReaderSettingsStore((s) => s.setShelfLayout)
+ const columns = useReaderSettingsStore((s) => s.shelfGridColumns)
+ const setColumns = useReaderSettingsStore((s) => s.setShelfGridColumns)
+ const sort = useReaderSettingsStore((s) => s.shelfSort)
+ const setSort = useReaderSettingsStore((s) => s.setShelfSort)
+ const showUnread = useReaderSettingsStore((s) => s.shelfShowUnread)
+ const setShowUnread = useReaderSettingsStore((s) => s.setShelfShowUnread)
+ const showUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime)
+ const setShowUpdateTime = useReaderSettingsStore((s) => s.setShelfShowUpdateTime)
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
+
+
+ {SHELF_LAYOUT_OPTIONS.map((option) => (
+ setLayout(option.value)}
+ />
+ ))}
+
+ {layout === 'grid' && (
+
+ 列数
+ {SHELF_GRID_COLUMNS.map((value) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {SHELF_SORT_OPTIONS.map((option) => (
+ setSort(option.value)}
+ />
+ ))}
+
+
+
+
+
+ setShowUnread(!showUnread)}
+ />
+ setShowUpdateTime(!showUpdateTime)}
+ />
+
+
+
+
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderAudioPanel.tsx b/web/src/pages/reader/ReaderAudioPanel.tsx
index 4446732..491b89c 100644
--- a/web/src/pages/reader/ReaderAudioPanel.tsx
+++ b/web/src/pages/reader/ReaderAudioPanel.tsx
@@ -21,6 +21,7 @@ import {
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
+import { readerCoverSrc } from '../../utils/readerCover'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
// hls.js 播 m3u8,