mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c8bfdd6c | |||
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c | |||
| 2c6646beea | |||
| 83ec494a36 | |||
| b8648f0049 | |||
| 5acfa6292a | |||
| 42343212c5 | |||
| 6584519bd8 |
+30
-2
@@ -26,6 +26,29 @@
|
||||
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
|
||||
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
|
||||
|
||||
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
|
||||
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
|
||||
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
|
||||
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
|
||||
- 显示项开关:未读章数徽标、更新时间。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
|
||||
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
|
||||
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
|
||||
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
|
||||
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
|
||||
|
||||
## 2. 阅读界面(重点)
|
||||
|
||||
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
|
||||
@@ -71,7 +94,12 @@
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
@@ -91,7 +119,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. 搜索
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
|
||||
//
|
||||
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
|
||||
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
|
||||
|
||||
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: bookURL,
|
||||
}, name, "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"groups": groups})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func namesOf(groups []model.BookGroupSet) string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, g.Name)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func idsOf(groups []model.BookGroupSet, name string) string {
|
||||
for _, g := range groups {
|
||||
if g.Name == name {
|
||||
return strings.Join(g.BookIDs, ",")
|
||||
}
|
||||
}
|
||||
return "<missing>"
|
||||
}
|
||||
|
||||
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
|
||||
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须是 [] 而不是 null
|
||||
if !strings.Contains(w.Body.String(), `"groups":[]`) {
|
||||
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
|
||||
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
|
||||
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
|
||||
{Name: "玄幻", BookIDs: []string{b3.ID}},
|
||||
{Name: "空组", BookIDs: []string{}},
|
||||
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
|
||||
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
|
||||
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
|
||||
}
|
||||
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
|
||||
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
|
||||
}
|
||||
if ids := idsOf(got, "在读"); ids != "" {
|
||||
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
|
||||
}
|
||||
if ids := idsOf(got, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
if ids := idsOf(got, "外来"); ids != "" {
|
||||
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
|
||||
}
|
||||
|
||||
// 读回来应当一致
|
||||
loaded, code := getBookGroups(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("读回 status=%d", code)
|
||||
}
|
||||
if names := namesOf(loaded); names != namesOf(got) {
|
||||
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
|
||||
}
|
||||
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
|
||||
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := getBookGroups(t, router, "u1")
|
||||
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
|
||||
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsArePerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u2 看不到 u1 的分组
|
||||
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
|
||||
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
|
||||
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
|
||||
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
|
||||
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
|
||||
}
|
||||
|
||||
// u1 的分组不受影响
|
||||
back, _ := getBookGroups(t, router, "u1")
|
||||
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
|
||||
t.Fatalf("u1 分组被 u2 影响: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
|
||||
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "旧组", BookIDs: []string{b1.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "新组", BookIDs: []string{}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "新组" {
|
||||
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
|
||||
}
|
||||
|
||||
// 清空分组
|
||||
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
|
||||
t.Fatalf("清空后读回 = %+v,期望空", groups)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@ 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))
|
||||
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
|
||||
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
|
||||
@@ -320,13 +333,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -360,6 +375,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||
// 「Cannot read properties of null」。空目录统一给 []。
|
||||
if chapters == nil {
|
||||
chapters = []reader.TocChapter{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
@@ -414,6 +434,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 {
|
||||
@@ -502,6 +544,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)
|
||||
@@ -513,11 +569,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 {
|
||||
@@ -525,7 +626,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
|
||||
}
|
||||
|
||||
@@ -66,5 +66,7 @@ func AllModels() []interface{} {
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
}
|
||||
}
|
||||
|
||||
+56
-21
@@ -10,24 +10,27 @@ 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),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
@@ -58,13 +61,17 @@ type ReaderBook struct {
|
||||
Charset string `gorm:"type:varchar(32)" json:"charset"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
|
||||
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
|
||||
// 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
|
||||
// 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
|
||||
LatestChapterTime int64 `json:"latest_chapter_time"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
|
||||
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
|
||||
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
|
||||
@@ -108,3 +115,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"`
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -286,8 +286,12 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc
|
||||
if ck := state.CookieForRequest(source); ck != "" {
|
||||
headers["Cookie"] = ck
|
||||
}
|
||||
// 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法,
|
||||
// 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。
|
||||
if headers["Referer"] == "" {
|
||||
headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/"
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
headers["Referer"] = referer
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:书籍类型兜底的回归测试。
|
||||
//
|
||||
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
|
||||
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
|
||||
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
|
||||
// 整个阅读页是一屏 URL(用户截图里的现象)。
|
||||
//
|
||||
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
|
||||
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
|
||||
|
||||
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
|
||||
func audioTestSourceJSON(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
return `{
|
||||
"bookSourceName": "听书测试源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + base + `",
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`
|
||||
}
|
||||
|
||||
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
|
||||
// 并把纠正后的类型写回书架记录。
|
||||
func TestAudioBookTypeFallbackBySource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
|
||||
|
||||
// 复刻详情页写死的 origin_type=0
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "听书测试源",
|
||||
OriginType: 0,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "测试听书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "audio" {
|
||||
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
|
||||
out.Type, out.Content)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("代理地址校验失败: %v", err)
|
||||
}
|
||||
if raw != "https://cdn.example.com/a.m4a" {
|
||||
t.Fatalf("代理还原地址 = %q", raw)
|
||||
}
|
||||
|
||||
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
|
||||
// book.type 也一直是错的(书源 JS 会读它分支)。
|
||||
reloaded, err := svc.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("重读书籍失败: %v", err)
|
||||
}
|
||||
if reloaded.Type != 1 {
|
||||
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
|
||||
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
|
||||
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
|
||||
src := `{
|
||||
"bookSourceName": "声明文本的音频源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"ruleContent": {"content": "@js:book.type = 8; result"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, src)
|
||||
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
|
||||
OriginType: 0, BookURL: srv.URL + "/book/1",
|
||||
}, "声明文本", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
|
||||
func trackQueryValue(t *testing.T, raw, key string) string {
|
||||
t.Helper()
|
||||
idx := strings.Index(raw, key+"=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
|
||||
}
|
||||
rest := raw[idx+len(key)+1:]
|
||||
if end := strings.Index(rest, "&"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 HTML。
|
||||
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。
|
||||
func TestImageRefsInLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}},
|
||||
{"双引号 img", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
|
||||
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
|
||||
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
|
||||
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
|
||||
{"img 没有 src 时跳过", `<img alt="空">`, nil},
|
||||
{"非 img 的 HTML 片段跳过", `</div>`, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := imageRefsInLine(c.line)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Fatalf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址,
|
||||
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
|
||||
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
page := `<html><body><div class="pages">
|
||||
<img src="/img/1.webp"><img src="/img/2.webp">
|
||||
</div></body></html>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "漫画测试源",
|
||||
"bookSourceType": 64,
|
||||
"ruleContent": { "content": "class.pages@html" }
|
||||
}`, srv.URL)
|
||||
sourceID := importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "漫画测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "航海王",
|
||||
Type: 2, // 图片
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
_ = sourceID
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "image" {
|
||||
t.Fatalf("类型 = %q,期望 image", out.Type)
|
||||
}
|
||||
if len(out.Images) != 2 {
|
||||
t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
// 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头),
|
||||
// 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。
|
||||
if !strings.HasPrefix(img, "/api/reader/media?") {
|
||||
t.Fatalf("第 %d 张不是代理地址:%q", i, img)
|
||||
}
|
||||
encoded := img[strings.Index(img, "u=")+2:]
|
||||
if end := strings.IndexByte(encoded, '&'); end >= 0 {
|
||||
encoded = encoded[:end]
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 张地址解不开:%q", i, img)
|
||||
}
|
||||
want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1)
|
||||
if string(raw) != want {
|
||||
t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法:
|
||||
//
|
||||
// $..url
|
||||
// <js>result.split("\n").map(x=>'<img src="'+x+'">').join("\n")</js>
|
||||
//
|
||||
// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串
|
||||
// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则),
|
||||
// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。
|
||||
func TestContentRuleJSReceivesJoinedString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
const srcURL = "https://www.mangacopy.com/"
|
||||
const contentRule = "$..url\n<js>result.split(\"\\n\").map(x=>'<img src=\"'+x+'\">').join(\"\\n\")</js>"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srcURL,
|
||||
RuleContent: &ContentRule{Content: strPtr(contentRule)},
|
||||
Header: &header,
|
||||
}
|
||||
|
||||
body := `{"results":{"chapter":{"contents":[` +
|
||||
`{"url":"https://img.example.com/1.webp"},` +
|
||||
`{"url":"https://img.example.com/2.webp"}]}}}`
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x")
|
||||
|
||||
got, err := ar.GetString(contentRule, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("正文规则求值失败: %v", err)
|
||||
}
|
||||
want := "<img src=\"https://img.example.com/1.webp\">\n<img src=\"https://img.example.com/2.webp\">"
|
||||
if got != want {
|
||||
t.Fatalf("正文 = %q\n期望 %q", got, want)
|
||||
}
|
||||
// 图片提取(漫画分支):每行一个 <img>,应抽出 2 个真实地址
|
||||
refs := 0
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
refs += len(imageRefsInLine(line))
|
||||
}
|
||||
if refs != 2 {
|
||||
t.Fatalf("从正文抽出 %d 张图,期望 2", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回,
|
||||
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
|
||||
func TestNormalizeCoverURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"blank", " ", ""},
|
||||
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
|
||||
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
|
||||
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
|
||||
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
|
||||
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
|
||||
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
|
||||
{
|
||||
"search params envelope",
|
||||
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
|
||||
"",
|
||||
},
|
||||
{"text data uri", "data:text/plain;base64,aGk=", ""},
|
||||
{"source name", "光遇聚合", ""},
|
||||
{"javascript url", "javascript:alert(1)", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeCoverURL(tc.in); got != tc.want {
|
||||
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -501,6 +501,11 @@ func (s *ReaderService) LocalChapterContent(
|
||||
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
|
||||
text = s.rewriteLocalImages(book.ID, text)
|
||||
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
|
||||
// 图片版 EPUB(整章都是 [img] 标记)同样是漫画:按图片类型下发,
|
||||
// 前端走漫画阅读器,才有整页缩放与双页铺开。
|
||||
if imgs, ok := imageMarkersOnly(text); ok {
|
||||
return &ChapterContent{Type: "image", Images: imgs, declaredType: -1}, nil
|
||||
}
|
||||
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
|
||||
@@ -41,3 +47,88 @@ func TestProxyURLSignRoundTrip(t *testing.T) {
|
||||
t.Fatalf("double wrap: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceReferer 验证默认 Referer 只在 origin 是真正的 http(s) 书源地址时
|
||||
// 才生成。聚合类书源的 origin 是显示名(如「光遇聚合」),拿它当 Referer 会被
|
||||
// 图床判盗链,返回一张「请到本网站阅读」的占位图。
|
||||
func TestSourceReferer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{"普通书源地址", "https://www.example.com", "https://www.example.com/"},
|
||||
{"带尾斜杠", "https://www.example.com/", "https://www.example.com/"},
|
||||
{"带子路径", "https://www.example.com/site", "https://www.example.com/site/"},
|
||||
{"聚合书源显示名", "光遇聚合", ""},
|
||||
{"空值", "", ""},
|
||||
{"只有空白", " ", ""},
|
||||
{"非 http 协议", "ftp://example.com", ""},
|
||||
{"没有主机", "https://", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sourceReferer(c.origin); got != c.want {
|
||||
t.Errorf("%s: sourceReferer(%q) = %q,期望 %q", c.name, c.origin, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaRetriesTransientFailure 验证图床偶发 403(并发限流)会被重试掉,
|
||||
// 否则漫画一屏并发取图时总有几页留成破图。
|
||||
func TestFetchMediaRetriesTransientFailure(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) < 3 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
_, _ = w.Write([]byte("real-image-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/1.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d,期望 200(前两次 403 应被重试掉)", resp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if string(data) != "real-image-bytes" {
|
||||
t.Fatalf("body = %q", data)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaDoesNotRetryNotFound 验证确定性的 4xx(如图片不存在)不做无谓重试,
|
||||
// 避免首屏白白多等两轮退避。
|
||||
func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/missing.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("状态码 = %d,期望 404", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRateLimiter 单源限速。
|
||||
//
|
||||
// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封
|
||||
// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。
|
||||
// 对应 legado 的 ConcurrentRateLimiter,支持两种写法:
|
||||
//
|
||||
// "1000" 每次请求至少间隔 1000ms
|
||||
// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔)
|
||||
//
|
||||
// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。
|
||||
type sourceRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
slots map[string]time.Time
|
||||
}
|
||||
|
||||
// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。
|
||||
const maxRateGapMs = 30000
|
||||
|
||||
func newSourceRateLimiter() *sourceRateLimiter {
|
||||
return &sourceRateLimiter{slots: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// wait 按源限速等待;key 为空或 rate 无法解析时不等待。
|
||||
func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) {
|
||||
gap := parseConcurrentRate(rate)
|
||||
if gap <= 0 || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
slot := now
|
||||
if next, ok := l.slots[key]; ok && next.After(now) {
|
||||
slot = next
|
||||
}
|
||||
l.slots[key] = slot.Add(gap)
|
||||
l.mu.Unlock()
|
||||
sleepWithContext(ctx, slot.Sub(now))
|
||||
}
|
||||
|
||||
// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。
|
||||
func parseConcurrentRate(rate string) time.Duration {
|
||||
rate = strings.TrimSpace(rate)
|
||||
if rate == "" {
|
||||
return 0
|
||||
}
|
||||
gapMs := 0
|
||||
if i := strings.IndexByte(rate, '/'); i > 0 {
|
||||
count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i]))
|
||||
interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:]))
|
||||
if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = interval / count
|
||||
} else {
|
||||
n, err := strconv.Atoi(rate)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = n
|
||||
}
|
||||
if gapMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
if gapMs > maxRateGapMs {
|
||||
gapMs = maxRateGapMs
|
||||
}
|
||||
return time.Duration(gapMs) * time.Millisecond
|
||||
}
|
||||
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseConcurrentRate 书源 concurrentRate 的两种写法。
|
||||
func TestParseConcurrentRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"abc", 0},
|
||||
{"1000", 1000 * time.Millisecond},
|
||||
{" 500 ", 500 * time.Millisecond},
|
||||
{"3/1000", 333 * time.Millisecond},
|
||||
{"1/2000", 2000 * time.Millisecond},
|
||||
{"3/0", 0},
|
||||
{"0/1000", 0},
|
||||
{"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseConcurrentRate(c.in); got != c.want {
|
||||
t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。
|
||||
func TestRateLimiterSpacesRequests(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
l.wait(t.Context(), "src", "120") // 120ms
|
||||
l.wait(t.Context(), "src", "120")
|
||||
if elapsed := time.Since(start); elapsed < 110*time.Millisecond {
|
||||
t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed)
|
||||
}
|
||||
// 不同源互不影响
|
||||
start = time.Now()
|
||||
l.wait(t.Context(), "other", "500")
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("换源后不该等待,实际 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。
|
||||
func TestRateLimiterIgnoresEmptyRate(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
l.wait(t.Context(), "src", "")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("未声明限速却等待了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryableStatus 只有可恢复的失败才重试。
|
||||
func TestRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 429, 500, 502, 503, 504} {
|
||||
if !retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 301, 400, 401, 404, 410} {
|
||||
if retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 不该重试", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+856
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestRewriteContentImageMarkers 文本型漫画源正文里的 <img> 要转成前端认识的 [img] 标记,
|
||||
// 且地址要绝对化、过签名代理(有些图需要书源防盗链头才取得到)。
|
||||
func TestRewriteContentImageMarkers(t *testing.T) {
|
||||
proxy := func(u string) string { return "/proxy?u=" + url.QueryEscape(u) }
|
||||
content := "第一章开始\n" +
|
||||
"<img src=\"https://img.example.com/1.webp\">\n" +
|
||||
"<img src='/img/2.webp'/>\n" +
|
||||
"中间夹了图片 <img src=\"https://img.example.com/3.webp\"> 的这一行不动\n" +
|
||||
"结束"
|
||||
got := rewriteContentImageMarkers(content, "https://site.example.com/ch/1", proxy)
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("行数 = %d,期望 5\n%s", len(lines), got)
|
||||
}
|
||||
if lines[0] != "第一章开始" || lines[4] != "结束" {
|
||||
t.Fatalf("普通文本行被改动: %q / %q", lines[0], lines[4])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], imgMarkerPrefix) {
|
||||
t.Fatalf("第 2 行没有变成 [img] 标记: %q", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[1], url.QueryEscape("https://img.example.com/1.webp")) {
|
||||
t.Fatalf("第 2 行地址没走代理: %q", lines[1])
|
||||
}
|
||||
// 相对地址按章节地址绝对化
|
||||
if !strings.Contains(lines[2], url.QueryEscape("https://site.example.com/img/2.webp")) {
|
||||
t.Fatalf("相对图片地址没有绝对化: %q", lines[2])
|
||||
}
|
||||
// 段落中间的内嵌图片保持原样,避免破坏文本源语义
|
||||
if !strings.Contains(lines[3], "<img src=") {
|
||||
t.Fatalf("段落中间的 <img> 不该被改写: %q", lines[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteContentImageMarkersWithoutProxy 不需要代理时(如预览链路)直接落到绝对地址。
|
||||
func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
got := rewriteContentImageMarkers(`<img src="https://img.example.com/1.webp">`, "https://site.example.com/ch/1", nil)
|
||||
if got != imgMarkerPrefix+"https://img.example.com/1.webp" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
// 没有 <img> 的内容原样返回(含 null 字节之外的普通文本)
|
||||
const plain = "纯文本\n第二行"
|
||||
if got := rewriteContentImageMarkers(plain, "", nil); got != plain {
|
||||
t.Fatalf("无图内容被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageMarkersOnly 整章都是 [img] 标记时判定为图片章(前端才能走漫画阅读器、
|
||||
// 双页铺开);只要掺了能读的正文就必须保持文本,不能把文字吃掉。
|
||||
func TestImageMarkersOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantOK bool
|
||||
wantURLs []string
|
||||
}{
|
||||
{
|
||||
name: "整章都是标记",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "夹着空行 / 孤立 html 标签 / 首尾空白",
|
||||
content: "\n" + imgMarkerPrefix + "/a.jpg\n \n<div>\n</div>\n< br >\n" +
|
||||
" " + imgMarkerPrefix + " /b.jpg \n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "掺了正文就保持文本(图 + 长段落)",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + "第一句话。\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "纯文本",
|
||||
content: "第一章\n正文正文\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "空正文",
|
||||
content: "\n\n \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "标记地址为空",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + " \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 「<3」这类不是标签,是有内容的一行,不能当成空白忽略
|
||||
name: "非标签的尖括号行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := imageMarkersOnly(c.content)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("%s: ok = %v,期望 %v(urls=%v)", c.name, ok, c.wantOK, got)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(got) != len(c.wantURLs) {
|
||||
t.Errorf("%s: urls = %v,期望 %v", c.name, got, c.wantURLs)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.wantURLs[i] {
|
||||
t.Errorf("%s: urls[%d] = %q,期望 %q", c.name, i, got[i], c.wantURLs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
|
||||
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
|
||||
func TestSearchCheckKeyWord(t *testing.T) {
|
||||
mk := func(kw string) *BookSource {
|
||||
return &BookSource{RuleSearch: &SearchRule{CheckKeyWord: &kw}}
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"登录武林系统", "登录武林系统"},
|
||||
{" 空格清理 ", "空格清理"},
|
||||
{"", ""},
|
||||
{"http://example.com", ""},
|
||||
{"a::b", ""},
|
||||
{"a++b", ""},
|
||||
{"a--b", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mk(c.in).SearchCheckKeyWord(); got != c.want {
|
||||
t.Errorf("SearchCheckKeyWord(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if (&BookSource{}).SearchCheckKeyWord() != "" {
|
||||
t.Fatal("没有 ruleSearch 时应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchReportsCheckKeyWordMiss 搜索 0 条且响应里没有书源声明的校验关键字时,
|
||||
// 必须给出「疑似风控」的原因,而不是静默返回 0 条 —— 用户才知道是源的问题。
|
||||
func TestSearchReportsCheckKeyWordMiss(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 风控/失效时站点返回的就是这种没有结果的响应
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err == nil {
|
||||
t.Fatal("0 结果且校验关键字缺失时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), kw) {
|
||||
t.Fatalf("错误信息里应带上校验关键字,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchKeepsEmptyResultWhenKeywordPresent 响应里有关键字(站点正常,只是没有匹配的书)
|
||||
// 时不该报错,只是结果为空。
|
||||
func TestSearchKeepsEmptyResultWhenKeywordPresent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"hint":"登录武林系统","results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
books, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("站点正常时不该报错: %v", err)
|
||||
}
|
||||
if len(books) != 0 {
|
||||
t.Fatalf("结果数 = %d,期望 0", len(books))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -326,13 +332,14 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
result = strings.Split(s, "\n")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
if lst, ok := result.([]string); ok {
|
||||
for _, u := range lst {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
@@ -357,6 +364,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
@@ -378,7 +411,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 +456,14 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
|
||||
}
|
||||
if isUrl {
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return a.baseUrl, nil
|
||||
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
|
||||
// 但 baseUrl 未必是地址:聚合类书源(如「光遇聚合」)的搜索请求地址
|
||||
// 本身就是 data:;base64,... 参数信封,直接回退会把信封当成封面/书址
|
||||
// 返回,前端 <img> 只能显示破图。故仅在 baseUrl 是 http(s) 地址时回退。
|
||||
if base := strings.TrimSpace(a.baseUrl); isAbsURL(base) {
|
||||
return base, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return a.absolutize(str), nil
|
||||
}
|
||||
@@ -444,7 +484,7 @@ func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) {
|
||||
if err := a.putRule(sourceRule.putMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
|
||||
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。
|
||||
// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址,
|
||||
// 回退结果会把封面/书址污染成非图片文本(前端 <img> 直接破图)。
|
||||
func TestGetStringURLFallbackSkipsNonHTTPBaseURL(t *testing.T) {
|
||||
const payload = `{"data":[{"book_name":"全球高武","author":"老鹰吃小鸡"}]}`
|
||||
const envelope = "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ=="
|
||||
|
||||
elementOf := func(t *testing.T, content, baseURL string) (*AnalyzeRule, any) {
|
||||
t.Helper()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(content, baseURL)
|
||||
els, err := a.GetElements("$.data")
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("GetElements: want 1 element, got %d", len(els))
|
||||
}
|
||||
return a, els[0]
|
||||
}
|
||||
|
||||
t.Run("空取值不会把 data: 信封当地址返回", func(t *testing.T) {
|
||||
a, el := elementOf(t, payload, envelope)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("封面应为空,实际 = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("baseUrl 是 http(s) 时仍按 legado 语义回退", func(t *testing.T) {
|
||||
const base = "https://www.example.com/search?q=x"
|
||||
a, el := elementOf(t, payload, base)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("want base fallback %q, got %q", base, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("规则有取值时原样返回", func(t *testing.T) {
|
||||
const withCover = `{"data":[{"book_name":"书","thumb_url":"https://img.example.com/c.jpg"}]}`
|
||||
a, el := elementOf(t, withCover, envelope)
|
||||
got, err := a.GetString("$.thumb_url", el, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if got != "https://img.example.com/c.jpg" {
|
||||
t.Fatalf("封面 = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
@@ -401,9 +402,38 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(defaultWebViewUA)
|
||||
})
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||
// 见 zh_convert.go 的说明。
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。
|
||||
set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) })
|
||||
// toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。
|
||||
set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) })
|
||||
// toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。
|
||||
set("toURL", func(call goja.FunctionCall) goja.Value {
|
||||
base := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
base = call.Arguments[1].String()
|
||||
}
|
||||
obj, err := newJSURLObject(vm, stringArg(call, 0), base)
|
||||
if err != nil {
|
||||
bridgeErr("toURL", err)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
// logType:调试用,打印值的类型(对应 legado 的日志实现)。
|
||||
set("logType", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) {
|
||||
r.cfg.Log("null")
|
||||
} else {
|
||||
r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export()))
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
@@ -496,21 +526,26 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
||||
// 服务端没有对应页面,谎报成功会让书源走错分支
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚
|
||||
// (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。
|
||||
unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason)))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
} {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name))
|
||||
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||
}
|
||||
|
||||
return o
|
||||
|
||||
@@ -11,5 +11,5 @@ var (
|
||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,8 @@ func jsonRead(root any, path string) any {
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
@@ -51,7 +55,63 @@ func jsonValueToString(ob any) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return anyToString(t)
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Request struct {
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
@@ -202,6 +204,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZhToSimplified 常用繁体字要真的转成简体。
|
||||
// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换,
|
||||
// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。
|
||||
func TestZhToSimplified(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"島忍者神龜": "岛忍者神龟",
|
||||
"航海王": "航海王",
|
||||
"我們的書": "我们的书",
|
||||
"學習園地": "学习园地",
|
||||
"": "",
|
||||
"abc123": "abc123", // 非中文原样返回
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToSimplified(in); got != want {
|
||||
t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhToTraditional 反向转换。
|
||||
func TestZhToTraditional(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"岛忍者神龟": "島忍者神龜",
|
||||
"我们的书": "我們的書",
|
||||
"学习园地": "學習園地",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToTraditional(in); got != want {
|
||||
t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。
|
||||
func TestZhConvertLeavesPlainTextAlone(t *testing.T) {
|
||||
const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴☠️"
|
||||
if got := zhToSimplified(s); got != s {
|
||||
t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。
|
||||
func TestZhDictParsed(t *testing.T) {
|
||||
zhInit()
|
||||
if len(zhT2S) < 3000 {
|
||||
t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S))
|
||||
}
|
||||
if len(zhS2T) < 3000 {
|
||||
t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T))
|
||||
}
|
||||
// 空格/竖线不属于任何键
|
||||
if _, ok := zhT2S[' ']; ok {
|
||||
t.Fatal("词典里混进了分隔符")
|
||||
}
|
||||
if strings.ContainsRune(zhT2SData, '\uFFFD') {
|
||||
t.Fatal("词典数据含替换符,生成过程有损")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。
|
||||
//
|
||||
// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把
|
||||
// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。
|
||||
// 解析不出来时原样返回,不做猜测。
|
||||
var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`)
|
||||
|
||||
var (
|
||||
zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
||||
zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000}
|
||||
zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000}
|
||||
)
|
||||
|
||||
// numChapter 把标题里第一处数字规整成阿拉伯数字。
|
||||
func numChapter(s string) string {
|
||||
loc := chapterTokenRe.FindStringIndex(s)
|
||||
if loc == nil {
|
||||
return s
|
||||
}
|
||||
n, ok := parseCNSNum(s[loc[0]:loc[1]])
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:]
|
||||
}
|
||||
|
||||
// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。
|
||||
func parseCNSNum(tok string) (int, bool) {
|
||||
if tok == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(tok); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来
|
||||
if digitsOnly(tok) {
|
||||
n := 0
|
||||
for _, r := range tok {
|
||||
n = n*10 + zhDigits[r]
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
total, section, num := 0, 0, 0
|
||||
seen := false
|
||||
for _, r := range tok {
|
||||
if d, ok := zhDigits[r]; ok {
|
||||
num = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if u, ok := zhUnits[r]; ok {
|
||||
if num == 0 {
|
||||
num = 1 // 「十二」这类省略了前导一的写法
|
||||
}
|
||||
section += num * u
|
||||
num = 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if big, ok := zhBigUnits[r]; ok {
|
||||
section += num
|
||||
if section == 0 {
|
||||
section = 1
|
||||
}
|
||||
total += section * big
|
||||
section, num = 0, 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !seen {
|
||||
return 0, false
|
||||
}
|
||||
return total + section + num, true
|
||||
}
|
||||
|
||||
// digitsOnly 判断字符串是否只由中文数字字符组成。
|
||||
func digitsOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := zhDigits[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。
|
||||
// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。
|
||||
func TestNumChapter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第一百零八章": "第108章",
|
||||
"第十二话": "第12话",
|
||||
"第3话": "第3话",
|
||||
"第一话 冒险的序幕": "第1话 冒险的序幕",
|
||||
"一〇二": "102",
|
||||
"两千零一": "2001",
|
||||
"航海王": "航海王", // 没有数字:原样返回
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := numChapter(in); got != want {
|
||||
t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。
|
||||
func TestJavaExtraHelpers(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
|
||||
v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.randomUUID() 失败: %v", err)
|
||||
}
|
||||
id := anyToString(v)
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("randomUUID 返回值不像 UUID: %q", id)
|
||||
}
|
||||
|
||||
v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.toNumChapter() 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第108章" {
|
||||
t.Fatalf("toNumChapter = %q,期望 第108章", got)
|
||||
}
|
||||
|
||||
// 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义)
|
||||
if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil {
|
||||
t.Fatal("java.deviceID() 应当抛异常")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
|
||||
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
|
||||
out, _ := json.Marshal(src)
|
||||
_ = prepareLoginSource(t, svc, string(out))
|
||||
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
|
||||
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
|
||||
|
||||
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
|
||||
t.Helper()
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": name,
|
||||
"searchUrl": serverURL + "/search?q={{key}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{
|
||||
"data": []map[string]string{{
|
||||
"book_name": bookName,
|
||||
"author": "作者",
|
||||
"book_url": "/book/1",
|
||||
}},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
srvA := scopeEchoServer(t, "甲书")
|
||||
defer srvA.Close()
|
||||
srvB := scopeEchoServer(t, "乙书")
|
||||
defer srvB.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
for _, raw := range []string{
|
||||
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||
} {
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sources, err := svc.ListSources(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idByName := map[string]string{}
|
||||
for _, s := range sources {
|
||||
idByName[s.Name] = s.ID
|
||||
}
|
||||
|
||||
names := func(books []SearchBook) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, b := range books {
|
||||
out[b.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("源A 不应失败: %+v", skipped)
|
||||
}
|
||||
if got := names(books); len(got) != 1 || !got["甲书"] {
|
||||
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
|
||||
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 {
|
||||
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool {
|
||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// SearchCheckKeyWord 书源在搜索规则里声明的校验关键字(legado SearchRule.checkKeyWord)。
|
||||
//
|
||||
// 源的作者用它证明「这个地址返回的确实是正常搜索结果」:校验时搜这个关键字,
|
||||
// 响应里应该出现它;被风控/换域名后响应里就没有了。legado 的 getCheckKeyword
|
||||
// 对含 http / :: / ++ / -- 的值不认(那是地址或扩展标记,不是关键字),这里保持一致。
|
||||
func (b *BookSource) SearchCheckKeyWord() string {
|
||||
if b == nil || b.RuleSearch == nil {
|
||||
return ""
|
||||
}
|
||||
kw := strings.TrimSpace(SPtr(b.RuleSearch.CheckKeyWord))
|
||||
if kw == "" || strings.Contains(kw, "http") || strings.Contains(kw, "::") ||
|
||||
strings.Contains(kw, "++") || strings.Contains(kw, "--") {
|
||||
return ""
|
||||
}
|
||||
return kw
|
||||
}
|
||||
|
||||
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||
@@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string {
|
||||
|
||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
func stripJSWrapper(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "@js:") {
|
||||
return s[len("@js:"):]
|
||||
out, _ := stripJSWrapperOK(s)
|
||||
return out
|
||||
}
|
||||
|
||||
// stripJSWrapperOK 与 stripJSWrapper 相同,但额外告知是否命中 JS 包装。
|
||||
// 「可能是 JSON 也可能是 JS 规则」的字段(如书源 header)需要区分这两者。
|
||||
func stripJSWrapperOK(s string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return trimmed[len("@js:"):], true
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body, true
|
||||
default:
|
||||
return trimmed, false
|
||||
}
|
||||
if strings.HasPrefix(s, "<js>") {
|
||||
s = s[len("<js>"):]
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchRule 搜索规则。
|
||||
@@ -110,6 +138,9 @@ type TocRule struct {
|
||||
ChapterURL *string `json:"chapterUrl"`
|
||||
IsVolume *string `json:"isVolume"`
|
||||
UpdateTime *string `json:"updateTime"`
|
||||
// NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它
|
||||
// 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。
|
||||
NextTocURL *string `json:"nextTocUrl"`
|
||||
}
|
||||
|
||||
// ContentRule 正文规则。
|
||||
@@ -125,25 +156,33 @@ type ContentRule struct {
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
type ExploreRule struct {
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
}
|
||||
|
||||
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||
//
|
||||
// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。
|
||||
// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败,
|
||||
// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。
|
||||
func ParseBookSource(raw string) (*BookSource, error) {
|
||||
var bs BookSource
|
||||
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
||||
return nil, err
|
||||
var relaxed BookSource
|
||||
if err2 := json.Unmarshal([]byte(normalizeSourceJSON(raw)), &relaxed); err2 != nil {
|
||||
return nil, err // 报原始错误:字段位置更贴近用户看到的 JSON
|
||||
}
|
||||
bs = relaxed
|
||||
}
|
||||
bs.RawJSON = raw
|
||||
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||
@@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) {
|
||||
return &bs, nil
|
||||
}
|
||||
|
||||
// sourceNumericFields 是书源里可能被写成字符串的数字字段。
|
||||
// 例:{"lastUpdateTime":"1788449879889"}(阅读本体导出/第三方源站的常见写法)。
|
||||
var sourceNumericFields = []string{
|
||||
"customOrder", "weight", "bookSourceType", "lastUpdateTime", "respondTime",
|
||||
}
|
||||
|
||||
// sourceRuleObjectFields 在 legado 里是对象,但部分导出工具把空规则写成 []。
|
||||
// 例:{"ruleExplore":[]}(空发现规则)。
|
||||
var sourceRuleObjectFields = []string{
|
||||
"ruleExplore", "ruleSearch", "ruleBookInfo", "ruleToc", "ruleContent",
|
||||
}
|
||||
|
||||
// normalizeSourceJSON 消化书源 JSON 与 legado 实体之间的形状差异:
|
||||
// - 数字字段被写成字符串 → 转成数字;
|
||||
// - 规则对象被写成空数组 [] → 转成空对象 {}。
|
||||
//
|
||||
// 只动顶层已知字段,规则 JS 字符串原样保留。解析不出来就原样返回,交给调用方报错。
|
||||
func normalizeSourceJSON(raw string) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return raw
|
||||
}
|
||||
changed := false
|
||||
for _, key := range sourceNumericFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
continue // 本来就是数字(或其它类型):不掺和
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
// 空字符串当成「没有这个字段」,否则会报类型错误
|
||||
delete(fields, key)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(text, 10, 64); err != nil {
|
||||
continue // 不是整数(如 "1.0"):不猜,让标准解析去报错
|
||||
}
|
||||
fields[key] = json.RawMessage(text)
|
||||
changed = true
|
||||
}
|
||||
for _, key := range sourceRuleObjectFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
fields[key] = json.RawMessage("{}")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||
func (b *BookSource) SourceProps() map[string]any {
|
||||
if b.RawJSON == "" {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRawSourceNeedsBrowser webView/webjs 类书源要在列表里先标出来
|
||||
// (服务端没有无头浏览器,这类源注定跑不通,不该等用户点进去才报错)。
|
||||
func TestRawSourceNeedsBrowser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"webView 选项", `{"searchUrl":"https://a.com/s,{webView:true}"}`, true},
|
||||
{"webjs 规则", `{"ruleContent":{"content":"@webjs:getDom()"}}`, true},
|
||||
{"规则里提到 webJs", `{"ruleToc":{"chapterList":"<js>if(supportWebJs){}</js>"}}`, true},
|
||||
{"普通源", `{"searchUrl":"https://a.com/s?q={{key}}","ruleSearch":{"bookList":"$.list[*]"}}`, false},
|
||||
{"空", ``, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rawSourceNeedsBrowser(c.raw); got != c.want {
|
||||
t.Errorf("%s: rawSourceNeedsBrowser = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// TestApplyHeadersEvaluatesJSHeader 覆盖拷贝漫画(优++)这类书源:
|
||||
// header 是 @js: 规则,运行时才用 baseUrl 拼出 platform/version/referer。
|
||||
// 此前 applyHeaders 只做 json.Unmarshal,JS 形态被整体丢掉,请求少了这些
|
||||
// 必需头,上游同样回 200 但结果是空列表 —— 表现为「源能导入却搜不到内容」。
|
||||
func TestApplyHeadersEvaluatesJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nJSON.stringify({\"platform\":\"1\",\"version\":\"9.9.9\",\"referer\":baseUrl})"
|
||||
src := &model.ReaderBookSource{
|
||||
Name: "拷贝漫画(优++)",
|
||||
SourceURL: "https://www.mangacopy.com/",
|
||||
Header: header,
|
||||
}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, BookSourceName: src.Name, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://api.mangacopy.com/api/v3/search/comic", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("火影", 1))
|
||||
|
||||
if got := req.Headers["platform"]; got != "1" {
|
||||
t.Fatalf("platform = %q,期望 \"1\"(JS 形态的 header 没有被执行)", got)
|
||||
}
|
||||
if got := req.Headers["version"]; got != "9.9.9" {
|
||||
t.Fatalf("version = %q,期望 \"9.9.9\"", got)
|
||||
}
|
||||
// baseUrl 必须绑成书源地址(对应 legado getHeaderMap 里的 getKey())
|
||||
if got := req.Headers["referer"]; got != "https://www.mangacopy.com/" {
|
||||
t.Fatalf("referer = %q,期望书源地址", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersKeepsPlainJSONHeader 直接写 JSON 的 header 不能回归。
|
||||
func TestApplyHeadersKeepsPlainJSONHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
header := `{"User-Agent":"MyBox/1.0","X-Token":"abc"}`
|
||||
src := &model.ReaderBookSource{Name: "纯 JSON 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if got := req.Headers["User-Agent"]; got != "MyBox/1.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
if got := req.Headers["X-Token"]; got != "abc" {
|
||||
t.Fatalf("X-Token = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersSurvivesBrokenJSHeader 坏掉的 header JS 不能 panic,
|
||||
// 也不该把半成品请求头发出去。
|
||||
func TestApplyHeadersSurvivesBrokenJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nthrow new Error('boom')"
|
||||
src := &model.ReaderBookSource{Name: "坏 header 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if len(req.Headers) != 0 {
|
||||
t.Fatalf("求值失败的 header 不应写入任何头,实际: %v", req.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// messySourceJSON 复刻真实「拷贝漫画(优++)」源文件的形状:
|
||||
// lastUpdateTime 是字符串、respondTime 是数字、ruleExplore 是空数组、
|
||||
// ruleBookInfo/ruleContent 里同时有数字与 JS 字符串。
|
||||
// 这类文件在阅读 App 里能导入,在这里曾经整体解析失败。
|
||||
const messySourceJSON = `[{
|
||||
"bookSourceComment": "//By情无羁25.04.09 使用需要魔法",
|
||||
"bookSourceGroup": "",
|
||||
"bookSourceName": "拷贝漫画(优++)",
|
||||
"bookSourceType": 0,
|
||||
"bookSourceUrl": "https://www.mangacopy.com/",
|
||||
"customOrder": 208,
|
||||
"enabled": true,
|
||||
"enabledCookieJar": false,
|
||||
"enabledExplore": true,
|
||||
"exploreUrl": "@js:\nsort = [];\nJSON.stringify(sort)",
|
||||
"header": "@js:\nJSON.stringify({\"platform\":\"1\",\"referer\":baseUrl})",
|
||||
"lastUpdateTime": "1788449879889",
|
||||
"respondTime": 181130,
|
||||
"ruleBookInfo": {"init": "$..comic", "intro": "$..brief"},
|
||||
"ruleContent": {"content": "$..url\n<js>result</js>", "imageStyle": "FULL"},
|
||||
"ruleExplore": [],
|
||||
"ruleSearch": {"bookList": "$..list[*]", "name": "$.name"},
|
||||
"ruleToc": {"chapterList": "$..list[*]", "chapterName": "name"},
|
||||
"searchUrl": "https://api.mangacopy.com/api/v3/search/comic?q={{key}}",
|
||||
"weight": 0
|
||||
}]`
|
||||
|
||||
// TestImportSourcesAcceptsMessySource 覆盖用户的真实场景:数字写法的字符串字段
|
||||
// (lastUpdateTime)与空数组写法的规则对象(ruleExplore)不该让整条书源导入失败。
|
||||
func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
imported, err := svc.ImportSources(ctx, messySourceJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("导入数量 = %d,期望 1", imported)
|
||||
}
|
||||
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
|
||||
if err != nil || bs == nil {
|
||||
t.Fatalf("按 URL 查不到导入的书源: %v", err)
|
||||
}
|
||||
if bs.Name != "拷贝漫画(优++)" {
|
||||
t.Fatalf("书源名 = %q", bs.Name)
|
||||
}
|
||||
// 关键字段要真的解出来,而不是「解成功了但字段全空」
|
||||
parsed, err := ParseBookSource(bs.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("重新解析失败: %v", err)
|
||||
}
|
||||
if parsed.LastUpdateTime == nil || *parsed.LastUpdateTime != 1788449879889 {
|
||||
t.Fatalf("lastUpdateTime = %v,期望 1788449879889", parsed.LastUpdateTime)
|
||||
}
|
||||
if parsed.RuleExplore == nil {
|
||||
t.Fatal("ruleExplore 应当被当成空规则对象,而不是解析失败")
|
||||
}
|
||||
if SPtr(parsed.RuleSearch.BookList) != "$..list[*]" {
|
||||
t.Fatalf("ruleSearch.bookList = %q", SPtr(parsed.RuleSearch.BookList))
|
||||
}
|
||||
if SPtr(parsed.RuleContent.ImageStyle) != "FULL" {
|
||||
t.Fatalf("ruleContent.imageStyle = %q", SPtr(parsed.RuleContent.ImageStyle))
|
||||
}
|
||||
// 落库的 raw 必须是原文(源 JS 可能依赖原样字段),不能被改写。
|
||||
// 这里特意按原文里的「冒号后有空格」写法比对:一旦被重新序列化就对不上了。
|
||||
if !strings.Contains(bs.RawJSON, `"lastUpdateTime": "1788449879889"`) {
|
||||
t.Fatalf("RawJSON 被改写了,应当保留原始文本;实际: %s", bs.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSourceJSON 单独锁定宽容规则本身。
|
||||
func TestNormalizeSourceJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 期望解析后可用的字段值 / 是否报错
|
||||
}{
|
||||
{"数字字符串", `{"weight":"12"}`, "12"},
|
||||
{"已经是数字", `{"weight":12}`, "12"},
|
||||
{"空字符串当缺省", `{"weight":""}`, "<nil>"},
|
||||
{"非整数不猜", `{"weight":"1.5"}`, "<err>"},
|
||||
{"空数组规则", `{"ruleExplore":[]}`, "obj"},
|
||||
{"正常规则不受影响", `{"ruleExplore":{"bookList":"a"}}`, "obj"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeSourceJSON(c.in)
|
||||
switch c.want {
|
||||
case "obj":
|
||||
if !strings.Contains(got, "{}") && !strings.Contains(got, `"bookList":"a"`) {
|
||||
t.Errorf("%s: normalizeSourceJSON(%s) = %s", c.name, c.in, got)
|
||||
}
|
||||
case "<nil>":
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight != nil {
|
||||
t.Errorf("%s: weight 应当为 nil,实际 %v", c.name, *bs.Weight)
|
||||
}
|
||||
case "<err>":
|
||||
if _, err := ParseBookSource(got); err == nil {
|
||||
t.Errorf("%s: 期望解析报错(不做无根据的猜测)", c.name)
|
||||
}
|
||||
default:
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight == nil || *bs.Weight != 12 {
|
||||
t.Errorf("%s: weight = %v,期望 12", c.name, bs.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBookSourceRejectsGarbage 真的坏掉的 JSON 仍要报错,不能因为宽容而静默吞掉。
|
||||
func TestParseBookSourceRejectsGarbage(t *testing.T) {
|
||||
if _, err := ParseBookSource(`{"bookSourceUrl":123}`); err == nil {
|
||||
t.Fatal("bookSourceUrl 是数字时应当报错")
|
||||
}
|
||||
if _, err := ParseBookSource(`{not json`); err == nil {
|
||||
t.Fatal("非法 JSON 应当报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSourcesReportsReasonWhenNothingImported 全部失败时必须把原因带回前端,
|
||||
// 否则界面只会弹「成功导入 0 个书源」,用户无从下手。
|
||||
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("一条都没导入时应当返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "解析失败") {
|
||||
t.Fatalf("错误信息应说明是解析失败,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestGetTocFollowsNextTocUrl 长书目录按 offset/limit 分页时必须翻页取全。
|
||||
// 这是「目录少章」的根因:nextTocUrl 没实现时只会拿到第一页
|
||||
// (实测某漫画站点 total=399/limit=100,MeBox 只得到 100 章)。
|
||||
func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"count":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"2": `{"count":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源(拷贝漫画)一致的写法:用 baseUrl 拼下一页地址
|
||||
nextTocURL := `<js>baseUrl.indexOf('offset=0') >= 0 ? baseUrl.replace('offset=0','offset=2') : ''</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "分页目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第二页没有被抓取)", len(chapters))
|
||||
}
|
||||
for i, want := range []string{"第1话", "第2话", "第3话", "第4话"} {
|
||||
if chapters[i].Title != want {
|
||||
t.Errorf("章节[%d] = %q,期望 %q", i, chapters[i].Title, want)
|
||||
}
|
||||
if chapters[i].Index != i {
|
||||
t.Errorf("章节[%d] 的 Index = %d,应按翻页顺序重排", i, chapters[i].Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlListsAllPagesFromFirstPage 书源只在第一页能算出完整后续页列表
|
||||
// (拷贝漫画的写法:规则锚定 offset=0,第二页上算出来还是同一批地址)。
|
||||
// 这种源必须把所有后续页一次性入队,否则只翻一页就停。
|
||||
func TestGetTocNextTocUrlListsAllPagesFromFirstPage(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"total":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"100": `{"total":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
hits = append(hits, offset)
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"total":4,"list":[]}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源一致:一次列出全部后续页,且锚点写死 offset=0(在第二页上等于原地踏步)
|
||||
nextTocURL := `<js>
|
||||
list=[];
|
||||
for(i=0;i<=1;i++){list.push(baseUrl.replace('offset=0','offset='+100*i))}
|
||||
list
|
||||
</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "全量下一页测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第一页列出的后续页没有被全部抓取)", len(chapters))
|
||||
}
|
||||
// 每页只抓一次(队列去重)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("上游请求次数 = %d(%v),期望 2", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlSelfReferenceStops nextTocUrl 指回当前页时必须停下,
|
||||
// 不能靠「规则永远给下一页」把自己转死。
|
||||
func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"list":[{"name":"第1话","url":"/c/1"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 永远返回同一页地址(用 baseUrl 原样返回)
|
||||
nextTocURL := `<js>baseUrl</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "自引用目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 1 {
|
||||
t.Fatalf("章节数 = %d,期望 1", len(chapters))
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("上游被请求 %d 次,自引用时应只请求 1 次", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// newTocFallbackBook 建一本书架记录:只存 book_url,toc_url 留空,
|
||||
// 模拟聚合类书源加入书架时的形态。
|
||||
func newTocFallbackBook(t *testing.T, svc *ReaderService, serverURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: serverURL,
|
||||
OriginName: "测试源",
|
||||
BookURL: serverURL + "/book/1",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(t.Context(), book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
// 聚合类书源(光遇聚合一类)把目录地址放在详情结果里,加书架时只存了 book_url。
|
||||
// 调用方又习惯把 book_url 当目录地址兜底(前端 toc_url || book_url),于是目录规则
|
||||
// 拿到的是书籍详情页 → 0 章 → 阅读页显示「目录为空」,漫画根本进不去。
|
||||
//
|
||||
// GetToc 必须自己补一次详情、用详情里的 tocUrl 重抓,并把地址写回书架。
|
||||
func TestGetTocFallsBackToBookInfoTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, "")
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应回退到详情里的 tocUrl 抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
|
||||
// 目录地址要写回书架:之后书架/阅读页/更新目录都不用再走一次详情
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stored.TocURL, "/book/1/toc") {
|
||||
t.Fatalf("目录地址未写回: %q", stored.TocURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 前端会把空 toc_url 兜底成 book_url 传进来,这条路径同样要能拿到目录。
|
||||
func TestGetTocTreatsBookURLAsMissingTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.BookURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("book_url 兜底的目录地址也应回退到详情,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
|
||||
// 正常源(详情给了独立目录页、调用方也传了)不能被这次改动影响。
|
||||
func TestGetTocKeepsGivenTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
book.TocURL = srv.URL + "/book/1/toc.html"
|
||||
if err := svc.repo.UpdateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.TocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应正常抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { appRoutes, type AppRoute } from './appRoutes'
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||
|
||||
@@ -93,6 +94,8 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
|
||||
<ReaderMiniPlayer />
|
||||
</Suspense>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
|
||||
+54
-6
@@ -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
|
||||
@@ -234,11 +245,13 @@ export const readerAPI = {
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
search: (key: string, sourceIds: string[] = []) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
{ key, source_ids: sourceIds },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
@@ -246,15 +259,17 @@ export const readerAPI = {
|
||||
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
|
||||
api
|
||||
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data.chapters),
|
||||
.get<{ chapters: ReaderTocChapter[] | null }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
// 后端把 nil 目录编码成 null;调用方一律按数组处理,
|
||||
// 否则 `chapters.length` 会抛 TypeError(页面只剩一句 JS 报错)。
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
|
||||
api
|
||||
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 书架 ──
|
||||
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
|
||||
listBooks: () => api.get<{ books: ReaderBook[] | null }>('/reader/books').then((r) => r.data.books ?? []),
|
||||
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||
@@ -283,13 +298,46 @@ export const readerAPI = {
|
||||
.post<ReaderBook>('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
|
||||
/**
|
||||
* 读取当前账号的阅读器偏好(主题 / 排版 / 听书 / 书架展示)。
|
||||
* 从未保存过时返回 null,由前端用本地的值播种。
|
||||
*/
|
||||
getReaderSettings: () =>
|
||||
api.get<{ profile: ReaderSettingsProfile | null }>('/reader/profile').then((r) => r.data.profile),
|
||||
/** 覆盖保存阅读器偏好(服务端会做范围收敛)。 */
|
||||
saveReaderSettings: (body: ReaderSettingsProfile) =>
|
||||
api.put<{ profile: ReaderSettingsProfile }>('/reader/profile', body).then((r) => r.data.profile),
|
||||
|
||||
// ── 书架分组(每个用户一份,仿影视模块的媒体库标签)──
|
||||
/** 读取当前账号的书架分组;没有分组时返回空数组。 */
|
||||
getBookGroups: () =>
|
||||
api.get<{ groups: BookGroup[] | null }>('/reader/book-groups').then((r) => r.data.groups ?? []),
|
||||
/** 覆盖保存书架分组:服务端会收敛成「一书一组」并丢弃已不在书架上的书。 */
|
||||
setBookGroups: (groups: BookGroup[]) =>
|
||||
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
|
||||
/**
|
||||
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
|
||||
*/
|
||||
refreshBooksToc: () =>
|
||||
api
|
||||
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
|
||||
.then((r) => r.data),
|
||||
/**
|
||||
* 换源:把书架里的书切到另一个书源。
|
||||
* 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
|
||||
*/
|
||||
switchOrigin: (id: string, origin: ReaderSearchOrigin) =>
|
||||
api.post<ReaderBook>(`/reader/books/${id}/origin`, { origin }, longOpts).then((r) => r.data),
|
||||
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
|
||||
api.put(`/reader/books/${id}/progress`, body),
|
||||
// 听书跳过片头/片尾(秒,0 不跳过)
|
||||
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||
api.put(`/reader/books/${id}/audio-config`, body),
|
||||
listChapters: (id: string) =>
|
||||
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||
api
|
||||
.get<{ chapters: ReaderChapter[] | null }>(`/reader/books/${id}/chapters`)
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FolderInput, Info, MoreVertical, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { ReaderBook } from '../api/reader'
|
||||
import { bookDetailPath } from '../pages/reader/bookshelfModel'
|
||||
|
||||
/**
|
||||
* 书籍卡片上的「⋯」操作菜单:把「移到分组 / 书籍详情 / 移出书架」收进一个下拉里,
|
||||
* 不再让三个图标直接铺在封面上。
|
||||
*
|
||||
* 定位上有三点刻意的处理:
|
||||
* 1. 菜单用 `position: fixed` + 实测位置渲染(而不是绝对定位挂在卡片里)。
|
||||
* 网格卡片为了裁圆角带 `overflow-hidden`,绝对定位的子元素会被裁掉;固定定位能
|
||||
* 逃出这个裁剪,也不用引入 portal。
|
||||
* 2. 网格布局下菜单与书籍卡片**同宽、左边缘对齐**、向下展开,看起来像卡片自己展开的
|
||||
* 动作面板;宽度取自卡片(`data-book-card` 标记的元素),列数变化时自动跟随。
|
||||
* 3. 下方确实放不下时才向上翻,并夹在视口内,避免贴底部的卡片点开看不见。
|
||||
*/
|
||||
export function BookCardMenu({
|
||||
book,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
variant = 'overlay',
|
||||
}: {
|
||||
book: ReaderBook
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
/** overlay:网格封面上的圆形按钮;plain:列表布局右侧的浅色按钮。 */
|
||||
variant?: 'overlay' | 'plain'
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
// 进场动画用:先以「缩起来」渲染一帧,再切到展开态触发过渡
|
||||
const [shown, setShown] = useState(false)
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number; up: boolean } | null>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
/** 列表布局(一行很宽)不适合跟行同宽,保持固定宽度。 */
|
||||
const PLAIN_WIDTH = 176
|
||||
/** 网格卡片过窄时给个下限,保证文字不挤成两行。 */
|
||||
const MIN_WIDTH = 132
|
||||
/** 三个菜单项 + 分隔线的大致高度,用来判断下方是否放得下。 */
|
||||
const MENU_HEIGHT = 140
|
||||
const GAP = 8
|
||||
const EDGE = 8
|
||||
|
||||
const openMenu = () => {
|
||||
const el = btnRef.current
|
||||
if (!el) return
|
||||
const btnRect = el.getBoundingClientRect()
|
||||
|
||||
// 网格:取卡片本身的位置与宽度(由 BookshelfCard 打上 data-book-card 标记)
|
||||
const cardEl = variant === 'overlay' ? el.closest('[data-book-card]') : null
|
||||
const cardRect = cardEl instanceof HTMLElement ? cardEl.getBoundingClientRect() : null
|
||||
|
||||
const width = cardRect
|
||||
? Math.max(MIN_WIDTH, Math.min(cardRect.width, window.innerWidth - EDGE * 2))
|
||||
: PLAIN_WIDTH
|
||||
const maxLeft = Math.max(EDGE, window.innerWidth - width - EDGE)
|
||||
const left = cardRect
|
||||
? Math.min(Math.max(EDGE, cardRect.left), maxLeft)
|
||||
: Math.min(Math.max(EDGE, btnRect.right - width), maxLeft)
|
||||
|
||||
// 默认向下;只有下方真的放不下才向上翻
|
||||
const spaceBelow = window.innerHeight - btnRect.bottom
|
||||
const up = spaceBelow < MENU_HEIGHT + GAP + EDGE && btnRect.top >= MENU_HEIGHT + GAP + EDGE
|
||||
const top = up
|
||||
? Math.max(EDGE, btnRect.top - GAP - MENU_HEIGHT)
|
||||
: Math.min(btnRect.bottom + GAP, Math.max(EDGE, window.innerHeight - MENU_HEIGHT - EDGE))
|
||||
|
||||
setShown(false)
|
||||
setPos({ top, left, width, up })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = () => setOpen(false)
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node
|
||||
if (btnRef.current?.contains(target)) return
|
||||
// 菜单本身在 fixed 层里,点它不算「点外部」
|
||||
if (target instanceof Element && target.closest('[data-book-card-menu]')) return
|
||||
close()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
// 滚动/缩放后按钮位置会变,菜单会脱位,直接关掉更省事
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// 挂载后下一帧再切到展开态,否则过渡不会触发
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const id = window.requestAnimationFrame(() => setShown(true))
|
||||
return () => window.cancelAnimationFrame(id)
|
||||
}, [open])
|
||||
|
||||
const run = (action: () => void) => {
|
||||
setOpen(false)
|
||||
action()
|
||||
}
|
||||
|
||||
const triggerClass =
|
||||
variant === 'overlay'
|
||||
? 'rounded-full bg-black/55 p-1.5 text-white opacity-85 backdrop-blur transition hover:bg-brand-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white'
|
||||
: 'rounded-lg p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
|
||||
// 每行自己声明完整配色(含文字颜色),避免同类 utility 互相覆盖
|
||||
const itemBase =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-xs font-bold transition-colors'
|
||||
const itemNormal = 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
const itemDanger = 'text-red-500/90 hover:bg-red-500/10 hover:text-red-500'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`更多操作:${book.name}`}
|
||||
title="更多操作"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (open) setOpen(false)
|
||||
else openMenu()
|
||||
}}
|
||||
className={triggerClass}
|
||||
>
|
||||
<MoreVertical size={variant === 'overlay' ? 13 : 16} />
|
||||
</button>
|
||||
|
||||
{open && pos && (
|
||||
<div
|
||||
data-book-card-menu=""
|
||||
role="menu"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
transformOrigin: pos.up ? 'bottom right' : 'top right',
|
||||
}}
|
||||
className={`fixed z-[120] rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1.5 shadow-2xl transition duration-100 ease-out ${
|
||||
shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" role="menuitem" className={`${itemBase} ${itemNormal}`} onClick={() => run(onPickGroup)}>
|
||||
<FolderInput size={14} className="shrink-0 text-brand-500" /> 移到分组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${itemBase} ${itemNormal}`}
|
||||
onClick={() => run(() => navigate(bookDetailPath(book)))}
|
||||
>
|
||||
<Info size={14} className="shrink-0 opacity-70" /> 书籍详情
|
||||
</button>
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${itemBase} ${itemDanger}`}
|
||||
onClick={() => run(onRemove)}
|
||||
>
|
||||
<Trash2 size={14} className="shrink-0" /> 移出书架
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Library, ListFilter } from 'lucide-react'
|
||||
|
||||
import type { BookGroupTab } from '../utils/readerBookGroups'
|
||||
|
||||
/**
|
||||
* 书架分组栏(对齐影视模块的 LibraryTagBar):
|
||||
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
|
||||
*
|
||||
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
|
||||
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
|
||||
*/
|
||||
export function BookGroupBar({
|
||||
tabs,
|
||||
selectedGroupId,
|
||||
onSelect,
|
||||
}: {
|
||||
tabs: BookGroupTab[]
|
||||
selectedGroupId: string
|
||||
onSelect: (groupId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-b border-[var(--app-border)] pb-3">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === selectedGroupId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
title={
|
||||
tab.kind === 'all'
|
||||
? '显示全部书籍'
|
||||
: tab.kind === 'ungrouped'
|
||||
? '显示没有归入任何分组的书籍'
|
||||
: `只看「${tab.name}」分组`
|
||||
}
|
||||
>
|
||||
{tab.kind === 'all' && <Library size={13} />}
|
||||
{tab.kind === 'ungrouped' && <ListFilter size={13} />}
|
||||
<span className="max-w-[8rem] truncate">{tab.name}</span>
|
||||
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Check, FolderInput, Layers, X } from 'lucide-react'
|
||||
|
||||
import type { BookGroup, ReaderBook } from '../api/reader'
|
||||
|
||||
/**
|
||||
* 「移到分组」选择框:给一本书指定所属分组。
|
||||
*
|
||||
* 书籍归组没有放在分组管理弹窗里 —— 书架可能有上千本,逐个列出来既慢又难找,
|
||||
* 直接在卡片上点「分组」更顺手。一本书只归一个分组,选「未分组」即移出所有分组。
|
||||
*/
|
||||
export function BookGroupPickerDialog({
|
||||
book,
|
||||
groups,
|
||||
currentGroupId,
|
||||
busy = false,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
groups: BookGroup[]
|
||||
/** 当前所属分组名;空串表示未分组。 */
|
||||
currentGroupId: string
|
||||
busy?: boolean
|
||||
onPick: (groupName: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="移到分组"
|
||||
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">移到分组</h3>
|
||||
<p className="mt-0.5 truncate text-2xs text-[var(--app-muted)]">《{book.name}》</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onPick('')}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<Layers size={14} className="shrink-0 text-[var(--app-muted)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">未分组</span>
|
||||
{currentGroupId === '' && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-2xs text-[var(--app-muted)]">
|
||||
还没有分组,先在书架分组栏的「管理分组」里新建一个
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<button
|
||||
key={group.name}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onPick(group.name)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<FolderInput size={14} className="shrink-0 text-brand-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{group.name}
|
||||
</span>
|
||||
{currentGroupId === group.name && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -12,6 +12,7 @@ import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import { LayoutReaderModeToggle } from './LayoutReaderModeToggle'
|
||||
import ReaderBookCover from './ReaderBookCover'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
||||
|
||||
@@ -248,19 +249,7 @@ function LayoutHeaderBookSearch() {
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<BookOpen size={14} />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={book.cover_url} iconSize={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, GripVertical, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { confirmAction } from './confirmAction'
|
||||
import { useDragReorder } from '../hooks/useDragReorder'
|
||||
import { MAX_BOOK_GROUPS, bookGroupNameError, normalizeBookGroupName } from '../utils/readerBookGroups'
|
||||
import type { BookGroup } from '../api/reader'
|
||||
|
||||
export type ManageBookGroupsDialogProps = {
|
||||
groups: BookGroup[]
|
||||
/** 分组名 → 当前书架里实际属于该组的书籍数(不统计已失效的 ID)。 */
|
||||
bookCounts: Record<string, number>
|
||||
saving: boolean
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRename: (from: string, to: string) => Promise<void>
|
||||
onRemove: (name: string) => Promise<void>
|
||||
/** 按给定分组名顺序重排(拖拽排序的落点)。 */
|
||||
onReorder: (orderedNames: string[]) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组管理对话框(对齐影视模块的管理标签弹窗,去掉成员分配那半边):
|
||||
* 新建 / 重命名 / 删除分组,拖拽调整分组栏顺序。
|
||||
*
|
||||
* 书籍归组不在这里做,而是在书架卡片上的「分组」按钮里逐个/批量指定 —— 书可能有上千本,
|
||||
* 逐个列在弹窗里既慢又难找。
|
||||
*/
|
||||
export function ManageBookGroupsDialogView({
|
||||
groups,
|
||||
bookCounts,
|
||||
saving,
|
||||
onCreate,
|
||||
onRename,
|
||||
onRemove,
|
||||
onReorder,
|
||||
onClose,
|
||||
}: ManageBookGroupsDialogProps) {
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [renaming, setRenaming] = useState('')
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const { draggingId, dragOverId, dragProps } = useDragReorder(
|
||||
groups.map((group) => group.name),
|
||||
onReorder,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const submitCreate = () => {
|
||||
const name = normalizeBookGroupName(draftName)
|
||||
if (!name) return
|
||||
const err = bookGroupNameError(draftName, groups)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
setDraftName('')
|
||||
void onCreate(name)
|
||||
}
|
||||
|
||||
const submitRename = (from: string) => {
|
||||
const name = normalizeBookGroupName(renameDraft)
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
if (!name || name === from) return
|
||||
const err = bookGroupNameError(renameDraft, groups, from)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
void onRename(from, name)
|
||||
}
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
const ok = await confirmAction({
|
||||
title: '删除分组',
|
||||
message: `删除「${name}」后,组内的书会回到「未分组」,书籍本身不会被移除。确定吗?`,
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
void onRemove(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="管理分组"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">管理分组</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
拖拽调整顺序 · 最多 {MAX_BOOK_GROUPS} 个分组
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-5 py-3">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitCreate()
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
placeholder="新分组名"
|
||||
className="h-9 min-w-0 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)] focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitCreate}
|
||||
disabled={saving || !draftName.trim() || groups.length >= MAX_BOOK_GROUPS}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-[var(--app-border)] px-3 py-2 text-xs font-bold text-brand-600 transition hover:bg-[var(--app-hover)] disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-[var(--app-muted)]">
|
||||
还没有分组,先在上方新建一个,再到书籍卡片上用「分组」按钮把书放进去
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.map((group) => {
|
||||
const isRenaming = renaming === group.name
|
||||
const dragging = draggingId === group.name
|
||||
const over = dragOverId === group.name
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
{...dragProps(group.name)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2 py-2 transition ${
|
||||
dragging
|
||||
? 'border-brand-500/60 opacity-60'
|
||||
: over
|
||||
? 'border-brand-500/60 bg-brand-500/5'
|
||||
: 'border-transparent hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 cursor-grab p-1 text-[var(--app-muted)] active:cursor-grabbing"
|
||||
title="拖拽调整顺序"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitRename(group.name)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
className="h-7 min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitRename(group.name)}
|
||||
className="rounded-lg p-1.5 text-brand-600 hover:bg-[var(--app-hover)]"
|
||||
title="保存"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||
{bookCounts[group.name] ?? 0} 本
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming(group.name)
|
||||
setRenameDraft(group.name)
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(group.name)}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-red-500/10 hover:text-red-500"
|
||||
title="删除分组"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saving && (
|
||||
<div className="flex items-center justify-center gap-2 border-t border-[var(--app-border)] py-2 text-2xs text-[var(--app-muted)]">
|
||||
<Loader2 size={12} className="animate-spin" /> 正在保存…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState, type SyntheticEvent } from 'react'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
import { isRenderableCover } from '../utils/coverUrl'
|
||||
import { readerCoverSrc } from '../utils/readerCover'
|
||||
|
||||
// 阅读子系统统一封面图。
|
||||
//
|
||||
// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
|
||||
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
|
||||
// 一律回退成占位图标。
|
||||
//
|
||||
// 另外,明文 http 的封面在 https 部署下会被混合内容策略拦掉,统一经后端图片
|
||||
// 代理下发(见 readerCoverSrc),否则这些封面会全部退化成占位图标。
|
||||
export default function ReaderBookCover({
|
||||
url,
|
||||
alt = '',
|
||||
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])
|
||||
|
||||
const src = readerCoverSrc(url)
|
||||
|
||||
// 后端图片代理抓不到上游时会回一张 1×1 透明 PNG(HTTP 200),<img> 因此不会
|
||||
// 触发 onError。书源在「没有封面」时可能返回源站首页这类非图片地址,代理同样
|
||||
// 只能给出这张占位像素——按加载失败处理,才会退回图标而不是留下一块空白。
|
||||
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) setFailed(true)
|
||||
}
|
||||
|
||||
if (!isRenderableCover(src) || failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className={className}
|
||||
onLoad={handleLoad}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
|
||||
|
||||
// Route guard: redirects to /login when no token is present.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null)
|
||||
const location = useLocation()
|
||||
|
||||
// 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
|
||||
// 登录 / 换账号时重新拉取(这里同时覆盖影视外壳和 /reader/* 独立布局)。
|
||||
useEffect(() => {
|
||||
startReaderSettingsSync()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
void hydrateReaderSettings()
|
||||
}, [userId])
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { BookGroup } from '../api/reader'
|
||||
import {
|
||||
ALL_GROUP_ID,
|
||||
assignBooksToGroup,
|
||||
attachBookToGroup,
|
||||
dedupeBookGroups,
|
||||
detachBooksFromGroup,
|
||||
loadBookGroups,
|
||||
normalizeBookGroups,
|
||||
normalizeBookGroupName,
|
||||
readCachedBookGroups,
|
||||
readSelectedGroupId,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
saveBookGroups,
|
||||
writeCachedBookGroups,
|
||||
writeSelectedGroupId,
|
||||
} from '../utils/readerBookGroups'
|
||||
|
||||
export type UseBookGroupsResult = {
|
||||
/** 当前用户维护的分组(顺序即分组栏顺序)。 */
|
||||
groups: BookGroup[]
|
||||
/** 分组数据是否仍在首次加载中。 */
|
||||
loading: boolean
|
||||
/** 是否有写入在途(可用于禁用重复点击)。 */
|
||||
saving: boolean
|
||||
/** 接口不可用(例如旧后端)时降级为本地分组。 */
|
||||
loadError: boolean
|
||||
/** 当前选中的分组栏:ALL_GROUP_ID 表示「全部」。 */
|
||||
selectedGroupId: string
|
||||
setSelectedGroupId: (groupId: string) => void
|
||||
createGroup: (name: string) => Promise<string>
|
||||
renameGroup: (from: string, to: string) => Promise<void>
|
||||
/** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */
|
||||
reorderGroups: (orderedNames: string[]) => Promise<void>
|
||||
removeGroup: (name: string) => Promise<void>
|
||||
/** 覆盖某个分组下的书籍集合(保持传入顺序)。 */
|
||||
setGroupBooks: (name: string, bookIds: string[]) => Promise<void>
|
||||
/** 把一本书归到分组下;groupName 为空表示移出所有分组。 */
|
||||
assignBook: (bookId: string, groupName: string) => Promise<void>
|
||||
/** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */
|
||||
assignBooks: (bookIds: string[], groupName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。
|
||||
*
|
||||
* 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`),
|
||||
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的
|
||||
* 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。
|
||||
*/
|
||||
export function useBookGroups(): UseBookGroupsResult {
|
||||
const [groups, setGroups] = useState<BookGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId())
|
||||
|
||||
const groupsRef = useRef<BookGroup[]>([])
|
||||
const selectedRef = useRef(selectedGroupId)
|
||||
const savedRef = useRef<BookGroup[]>([])
|
||||
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
const pendingRef = useRef(0)
|
||||
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
|
||||
const localOnlyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
groupsRef.current = groups
|
||||
}, [groups])
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedGroupId
|
||||
}, [selectedGroupId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
loadBookGroups()
|
||||
.then((rows) => {
|
||||
if (cancelled) return
|
||||
groupsRef.current = rows
|
||||
savedRef.current = rows
|
||||
setGroups(rows)
|
||||
setLoadError(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
// 旧后端没有 /reader/book-groups:用本地缓存兜底,分组栏照常可用。
|
||||
const cached = readCachedBookGroups()
|
||||
groupsRef.current = cached
|
||||
savedRef.current = cached
|
||||
setGroups(cached)
|
||||
setLoadError(true)
|
||||
localOnlyRef.current = isMissingEndpoint(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 选中的分组可能被删除,落回「全部」以免分组栏空白。
|
||||
// 首次加载完成前 groups 为空集合,此时不能据此判定分组已被删除。
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
const resolved = resolveSelectedGroupId(groups, selectedGroupId)
|
||||
if (resolved !== selectedGroupId) {
|
||||
selectedRef.current = resolved
|
||||
setSelectedGroupIdState(resolved)
|
||||
}
|
||||
}, [loading, groups, selectedGroupId])
|
||||
|
||||
const setSelectedGroupId = useCallback((groupId: string) => {
|
||||
const next = groupId && groupId !== ALL_GROUP_ID ? groupId : ALL_GROUP_ID
|
||||
selectedRef.current = next
|
||||
writeSelectedGroupId(next)
|
||||
setSelectedGroupIdState(next)
|
||||
}, [])
|
||||
|
||||
const mutate = useCallback((updater: (current: BookGroup[]) => BookGroup[]) => {
|
||||
const rollback = savedRef.current
|
||||
const next = dedupeBookGroups(normalizeBookGroups(updater(groupsRef.current)))
|
||||
groupsRef.current = next
|
||||
setGroups(next)
|
||||
|
||||
pendingRef.current += 1
|
||||
setSaving(true)
|
||||
const task = chainRef.current.then(async () => {
|
||||
// 发送排队时的最新本地状态:链上更早的写入已被后面的状态取代。
|
||||
const snapshot = groupsRef.current
|
||||
if (localOnlyRef.current) {
|
||||
if (groupsRef.current === snapshot) savedRef.current = snapshot
|
||||
writeCachedBookGroups(snapshot)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await saveBookGroups(snapshot)
|
||||
savedRef.current = saved
|
||||
setLoadError(false)
|
||||
if (groupsRef.current === snapshot) {
|
||||
groupsRef.current = saved
|
||||
setGroups(saved)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMissingEndpoint(err)) {
|
||||
// 旧后端不支持分组接口:本次修改保留在本地存储,分组栏照常可用。
|
||||
localOnlyRef.current = true
|
||||
savedRef.current = snapshot
|
||||
writeCachedBookGroups(snapshot)
|
||||
setLoadError(true)
|
||||
return
|
||||
}
|
||||
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的分组。
|
||||
groupsRef.current = rollback
|
||||
savedRef.current = rollback
|
||||
setGroups(rollback)
|
||||
setLoadError(true)
|
||||
toast.error('分组保存失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
chainRef.current = task.catch(() => undefined)
|
||||
return task.finally(() => {
|
||||
pendingRef.current = Math.max(0, pendingRef.current - 1)
|
||||
if (pendingRef.current === 0) setSaving(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createGroup = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = normalizeBookGroupName(name)
|
||||
if (!trimmed) return ''
|
||||
if (groupsRef.current.some((group) => group.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||
return trimmed
|
||||
}
|
||||
await mutate((current) => [...current, { name: trimmed, book_ids: [] }])
|
||||
return groupsRef.current.some((group) => group.name === trimmed) ? trimmed : ''
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const renameGroup = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
const trimmed = normalizeBookGroupName(to)
|
||||
if (!trimmed || trimmed === from) return
|
||||
await mutate((current) =>
|
||||
current.map((group) => (group.name === from ? { ...group, name: trimmed } : group)),
|
||||
)
|
||||
// 重命名后选中项要跟着换到新名字,否则分组栏会落回「全部」。
|
||||
if (selectedRef.current === from) setSelectedGroupId(trimmed)
|
||||
},
|
||||
[mutate, setSelectedGroupId],
|
||||
)
|
||||
|
||||
const reorderGroups = useCallback(
|
||||
async (orderedNames: string[]) => {
|
||||
await mutate((current) => reorderBookGroups(current, orderedNames))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const removeGroup = useCallback(
|
||||
async (name: string) => {
|
||||
await mutate((current) => current.filter((group) => group.name !== name))
|
||||
if (selectedRef.current === name) setSelectedGroupId(ALL_GROUP_ID)
|
||||
},
|
||||
[mutate, setSelectedGroupId],
|
||||
)
|
||||
|
||||
const setGroupBooks = useCallback(
|
||||
async (name: string, bookIds: string[]) => {
|
||||
await mutate((current) =>
|
||||
current.map((group) => (group.name === name ? { ...group, book_ids: bookIds } : group)),
|
||||
)
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignBook = useCallback(
|
||||
async (bookId: string, groupName: string) => {
|
||||
await mutate((current) =>
|
||||
groupName ? attachBookToGroup(current, bookId, groupName) : detachBooksFromGroup(current, [bookId]),
|
||||
)
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignBooks = useCallback(
|
||||
async (bookIds: string[], groupName: string) => {
|
||||
await mutate((current) => assignBooksToGroup(current, bookIds, groupName))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
selectedGroupId,
|
||||
setSelectedGroupId,
|
||||
createGroup,
|
||||
renameGroup,
|
||||
reorderGroups,
|
||||
removeGroup,
|
||||
setGroupBooks,
|
||||
assignBook,
|
||||
assignBooks,
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
|
||||
function isMissingEndpoint(err: unknown): boolean {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
return status === 404 || status === 405 || status === 501
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { BookOpen, Clock, HardDrive, History, UserRound } from 'lucide-react'
|
||||
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { BookCardMenu } from '../../components/BookCardMenu'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import type { ReaderShelfLayout } from '../../stores/readerSettings'
|
||||
import { formatRelativeTime, readProgressText, shelfUpdateTime, unreadChapters } from './bookshelfModel'
|
||||
|
||||
// 书架卡片:网格 / 列表 / 紧凑列表三种布局共用。
|
||||
//
|
||||
// 信息层级对齐 legado 书架物品:
|
||||
// 网格 —— 封面 + 本地角标 + 未读徽标 + 书名(两行)
|
||||
// 列表 —— 封面 + 书名 + 作者 + 读到 + 最新章节 + 更新时间 + 未读徽标
|
||||
// 紧凑 —— 小封面 + 书名 + 作者 · 读到
|
||||
// 点卡片进阅读器;「详情」进书籍详情页;「移出」二次确认由调用方处理。
|
||||
|
||||
function UnreadBadge({ book, compact = false }: { book: ReaderBook; compact?: boolean }) {
|
||||
const unread = unreadChapters(book)
|
||||
if (unread === null) return null
|
||||
return (
|
||||
<span
|
||||
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||
className={`shrink-0 whitespace-nowrap rounded-md font-bold ${
|
||||
compact ? 'px-1 py-px text-[10px]' : 'px-1.5 py-0.5 text-2xs'
|
||||
} ${unread > 0 ? 'bg-rose-500/90 text-white' : 'bg-[var(--app-hover)] text-[var(--app-muted)]'}`}
|
||||
>
|
||||
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalBadge() {
|
||||
return (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||
<HardDrive size={10} /> 本地
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GridCard({
|
||||
book,
|
||||
showUnread,
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
showUnread: boolean
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="group">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-book-card=""
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}}
|
||||
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是详情/移出按钮) */}
|
||||
{book.is_local && (
|
||||
<span className="absolute bottom-1 left-1">
|
||||
<LocalBadge />
|
||||
</span>
|
||||
)}
|
||||
{showUnread && unreadChapters(book) !== null && (
|
||||
<span className="absolute right-1 top-1">
|
||||
<UnreadBadge book={book} />
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1">
|
||||
<BookCardMenu book={book} variant="overlay" onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs font-bold leading-snug text-[var(--app-text)]">{book.name}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListCard({
|
||||
book,
|
||||
showUnread,
|
||||
showUpdateTime,
|
||||
groupName = '',
|
||||
compact = false,
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
showUnread: boolean
|
||||
showUpdateTime: boolean
|
||||
/** 所属分组名;空串表示未分组。 */
|
||||
groupName?: string
|
||||
compact?: boolean
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const read = readProgressText(book)
|
||||
const updated = shelfUpdateTime(book)
|
||||
const coverClass = compact ? 'h-16 w-12' : 'h-[90px] w-[66px]'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}}
|
||||
className={`group relative flex cursor-pointer gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-2.5 shadow-sm transition hover:border-brand-500/40 hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
compact ? 'items-center' : 'items-stretch'
|
||||
}`}
|
||||
>
|
||||
<div className={`relative shrink-0 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] ${coverClass}`}>
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={compact ? 16 : 20} />
|
||||
{book.is_local && (
|
||||
<span className="absolute left-0.5 top-0.5 rounded-md bg-black/55 p-0.5 text-white backdrop-blur">
|
||||
<HardDrive size={9} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]">{book.name}</p>
|
||||
{showUnread && <UnreadBadge book={book} compact={compact} />}
|
||||
</div>
|
||||
|
||||
{compact ? (
|
||||
<p className="flex min-w-0 items-center gap-1 truncate text-2xs text-[var(--app-muted)]">
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{read && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="truncate">读到 {read}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<UserRound size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{groupName && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-px text-[10px] font-bold text-brand-600">
|
||||
{groupName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showUpdateTime && updated > 0 && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<Clock size={11} className="shrink-0 opacity-60" />
|
||||
<span className="shrink-0">{formatRelativeTime(updated)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<History size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">{read ? `读到 ${read}` : '尚未开始阅读'}</span>
|
||||
</p>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<BookOpen size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">
|
||||
最新 {book.latest_chapter_title || '未知'}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<BookCardMenu book={book} variant="plain" onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookshelfCard({
|
||||
book,
|
||||
layout,
|
||||
showUnread,
|
||||
showUpdateTime,
|
||||
groupName = '',
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
layout: ReaderShelfLayout
|
||||
showUnread: boolean
|
||||
showUpdateTime: boolean
|
||||
/** 所属分组名;空串表示未分组(只在列表布局里展示)。 */
|
||||
groupName?: string
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<GridCard book={book} showUnread={showUnread} onOpen={onOpen} onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ListCard
|
||||
book={book}
|
||||
compact={layout === 'compact'}
|
||||
showUnread={showUnread}
|
||||
showUpdateTime={showUpdateTime}
|
||||
groupName={groupName}
|
||||
onOpen={onOpen}
|
||||
onPickGroup={onPickGroup}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Check, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
SHELF_GRID_COLUMNS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { SHELF_LAYOUT_OPTIONS, SHELF_SORT_OPTIONS, gridColumnsLabel } from './bookshelfModel'
|
||||
|
||||
// 书架设置弹窗(对应 legado 的 dialog_bookshelf_config):
|
||||
// 布局(列表 / 紧凑列表 / 网格 + 列数)、排序、以及显示项开关。
|
||||
// 所有选项直接落到 readerSettings store,关掉弹窗即生效。
|
||||
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] px-5 py-4 first:border-t-0">
|
||||
<p className="text-xs font-bold text-[var(--app-text)]">{title}</p>
|
||||
{hint && <p className="mt-0.5 text-2xs text-[var(--app-muted)]">{hint}</p>}
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioOption({
|
||||
active,
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
hint?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-left transition ${
|
||||
active
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-[var(--app-border)] hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block truncate text-xs font-bold ${active ? 'text-brand-600' : 'text-[var(--app-text)]'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="block truncate text-2xs text-[var(--app-muted)]">{hint}</span>}
|
||||
</span>
|
||||
{active && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleOption({
|
||||
active,
|
||||
label,
|
||||
hint,
|
||||
onToggle,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
hint?: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={active}
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-bold text-[var(--app-text)]">{label}</span>
|
||||
{hint && <span className="block text-2xs text-[var(--app-muted)]">{hint}</span>}
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition ${active ? 'bg-brand-500' : 'bg-[var(--app-border)]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
active ? 'left-[1.125rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookshelfSettingsDialog({ onClose }: { onClose: () => void }) {
|
||||
const layout = useReaderSettingsStore((s) => s.shelfLayout)
|
||||
const setLayout = useReaderSettingsStore((s) => s.setShelfLayout)
|
||||
const columns = useReaderSettingsStore((s) => s.shelfGridColumns)
|
||||
const setColumns = useReaderSettingsStore((s) => s.setShelfGridColumns)
|
||||
const sort = useReaderSettingsStore((s) => s.shelfSort)
|
||||
const setSort = useReaderSettingsStore((s) => s.setShelfSort)
|
||||
const showUnread = useReaderSettingsStore((s) => s.shelfShowUnread)
|
||||
const setShowUnread = useReaderSettingsStore((s) => s.setShelfShowUnread)
|
||||
const showUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime)
|
||||
const setShowUpdateTime = useReaderSettingsStore((s) => s.setShelfShowUpdateTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="书架设置"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">书架设置</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">布局、排序与显示项</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Section title="布局" hint="网格可再选列数,自适应会随屏幕宽度变化">
|
||||
<div role="radiogroup" className="grid gap-2">
|
||||
{SHELF_LAYOUT_OPTIONS.map((option) => (
|
||||
<RadioOption
|
||||
key={option.value}
|
||||
active={layout === option.value}
|
||||
label={option.label}
|
||||
hint={option.hint}
|
||||
onClick={() => setLayout(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{layout === 'grid' && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-1 text-2xs text-[var(--app-muted)]">列数</span>
|
||||
{SHELF_GRID_COLUMNS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setColumns(value)}
|
||||
className={`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${
|
||||
columns === value
|
||||
? 'border-brand-500 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
{gridColumnsLabel(value)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="排序">
|
||||
<div role="radiogroup" className="grid grid-cols-2 gap-2">
|
||||
{SHELF_SORT_OPTIONS.map((option) => (
|
||||
<RadioOption
|
||||
key={option.value}
|
||||
active={sort === option.value}
|
||||
label={option.label}
|
||||
onClick={() => setSort(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="显示项">
|
||||
<div className="grid gap-1">
|
||||
<ToggleOption
|
||||
active={showUnread}
|
||||
label="未读章数"
|
||||
hint="封面右上角显示还剩多少章没读"
|
||||
onToggle={() => setShowUnread(!showUnread)}
|
||||
/>
|
||||
<ToggleOption
|
||||
active={showUpdateTime}
|
||||
label="更新时间"
|
||||
hint="列表布局显示最近一次检测到新章节的时间"
|
||||
onToggle={() => setShowUpdateTime(!showUpdateTime)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
ListMusic,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Timer,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import type { ReaderChapter } from '../../api/reader'
|
||||
import {
|
||||
AUDIO_CREDITS_MAX,
|
||||
AUDIO_SPEEDS,
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
@@ -28,30 +14,12 @@ import {
|
||||
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||
//
|
||||
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
|
||||
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
|
||||
|
||||
interface ReaderAudioPanelProps {
|
||||
src: string
|
||||
title: string
|
||||
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
|
||||
cover: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
initialPos: number // 恢复进度(秒)
|
||||
/** 片头跳过秒数(Book.openCredits)。 */
|
||||
openCredits: number
|
||||
/** 片尾跳过秒数(Book.closeCredits)。 */
|
||||
closeCredits: number
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number | null
|
||||
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
|
||||
hasNext: boolean
|
||||
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
onProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
@@ -77,54 +45,41 @@ function fmtCountdown(sec: number): string {
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
cover,
|
||||
theme,
|
||||
initialPos,
|
||||
openCredits,
|
||||
closeCredits,
|
||||
chapters,
|
||||
chapterIndex,
|
||||
hasNext,
|
||||
transcoding = false,
|
||||
onProgress,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onSelectChapter,
|
||||
onEnded,
|
||||
onCreditsChange,
|
||||
onToggleMenu,
|
||||
}: ReaderAudioPanelProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||
const restoredRef = useRef(false)
|
||||
const skippedEndRef = useRef(false)
|
||||
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
|
||||
const engine = useMemo(() => getReaderAudioEngine(), [])
|
||||
|
||||
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const chapters = useReaderAudioStore((s) => s.chapters)
|
||||
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
const audioError = useReaderAudioStore((s) => s.error)
|
||||
const cur = useReaderAudioStore((s) => s.cur)
|
||||
const dur = useReaderAudioStore((s) => s.dur)
|
||||
const speed = useReaderAudioStore((s) => s.speed)
|
||||
const transcoding = useReaderAudioStore((s) => s.transcoding)
|
||||
const openCredits = useReaderAudioStore((s) => s.openCredits)
|
||||
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
|
||||
const timerActive = useReaderAudioStore((s) => s.timerActive)
|
||||
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
|
||||
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
|
||||
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [cur, setCur] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||
const [audioError, setAudioError] = useState('')
|
||||
const [sheet, setSheet] = useState<Sheet>('none')
|
||||
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
|
||||
const [groupIndex, setGroupIndex] = useState(0)
|
||||
|
||||
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||
const [coverOK, setCoverOK] = useState(true)
|
||||
useEffect(() => {
|
||||
setCoverOK(true)
|
||||
}, [cover])
|
||||
|
||||
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||
const [timerLeft, setTimerLeft] = useState(0)
|
||||
const [timerActive, setTimerActive] = useState(false)
|
||||
const [timerPreset, setTimerPreset] = useState(defaultTimer)
|
||||
const timerDeadlineRef = useRef(0)
|
||||
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
|
||||
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
|
||||
@@ -132,133 +87,16 @@ export function ReaderAudioPanel({
|
||||
setCreditDraft({ open: openCredits, close: closeCredits })
|
||||
}, [openCredits, closeCredits])
|
||||
|
||||
// ── 换源/换章:重建播放器 ──
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
restoredRef.current = false
|
||||
skippedEndRef.current = false
|
||||
setLoading(true)
|
||||
setAudioError('')
|
||||
setCur(0)
|
||||
setDur(0)
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
const setup = async () => {
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (cancelled) return
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(audio)
|
||||
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoading(false)
|
||||
})
|
||||
hls = inst
|
||||
hlsRef.current = inst
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = src // Safari 原生 HLS
|
||||
}
|
||||
} else {
|
||||
audio.src = src
|
||||
}
|
||||
}
|
||||
void setup()
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.playbackRate = speed
|
||||
}, [speed])
|
||||
|
||||
// ── 定时关闭 ──
|
||||
const applyTimer = useCallback(
|
||||
(minutes: number) => {
|
||||
setDefaultTimer(minutes)
|
||||
setTimerPreset(minutes)
|
||||
if (minutes <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerLeft(0)
|
||||
setTimerActive(false)
|
||||
return
|
||||
}
|
||||
timerDeadlineRef.current = Date.now() + minutes * 60_000
|
||||
setTimerLeft(minutes * 60)
|
||||
setTimerActive(true)
|
||||
},
|
||||
[setDefaultTimer],
|
||||
)
|
||||
|
||||
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
const timerBootRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (timerBootRef.current) return
|
||||
timerBootRef.current = true
|
||||
if (defaultTimer > 0) applyTimer(defaultTimer)
|
||||
}, [applyTimer, defaultTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerActive) return
|
||||
const id = window.setInterval(() => {
|
||||
const audio = audioRef.current
|
||||
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
|
||||
if (audio && audio.paused) {
|
||||
timerDeadlineRef.current += 1000
|
||||
return
|
||||
}
|
||||
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
|
||||
setTimerLeft(left)
|
||||
if (left <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerActive(false)
|
||||
audio?.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [timerActive])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play().catch(() => undefined)
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
const seekBy = useCallback((delta: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
|
||||
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
|
||||
audio.currentTime = next
|
||||
setCur(next)
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback((seconds: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = seconds
|
||||
setCur(seconds)
|
||||
}, [])
|
||||
|
||||
const changeSpeed = (v: number) => {
|
||||
setSpeed(v)
|
||||
}
|
||||
const toggle = () => engine.toggle()
|
||||
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
|
||||
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
|
||||
const changeSpeed = (v: number) => engine.setSpeed(v)
|
||||
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
|
||||
|
||||
const saveCredits = (open: number, close: number) => {
|
||||
setCreditDraft({ open, close })
|
||||
onCreditsChange(open, close)
|
||||
engine.setCredits(open, close)
|
||||
}
|
||||
|
||||
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||
@@ -274,12 +112,29 @@ export function ReaderAudioPanel({
|
||||
|
||||
const closeSheet = () => setSheet('none')
|
||||
|
||||
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
|
||||
const openSheet = (next: Sheet) => {
|
||||
if (sheet === next) {
|
||||
setSheet('none')
|
||||
return
|
||||
}
|
||||
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
|
||||
setSheet(next)
|
||||
}
|
||||
|
||||
const chapterList = useMemo(
|
||||
() => chapters.map((c, i) => ({ ...c, i })),
|
||||
[chapters],
|
||||
)
|
||||
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
|
||||
|
||||
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
|
||||
const chapterGroups = useMemo(() => buildChapterGroups(chapterList.length), [chapterList.length])
|
||||
|
||||
const jumpToChapterGroup = (next: number) => {
|
||||
const group = chapterGroups[next]
|
||||
if (!group) return
|
||||
setGroupIndex(next)
|
||||
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -291,10 +146,10 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
|
||||
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
|
||||
{cover && coverOK && (
|
||||
{coverSrc && coverOK && (
|
||||
<>
|
||||
<img
|
||||
src={cover}
|
||||
src={coverSrc}
|
||||
alt=""
|
||||
aria-hidden
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -316,9 +171,9 @@ export function ReaderAudioPanel({
|
||||
style={{ borderColor: theme.accent + '66', color: theme.accent }}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden rounded-full">
|
||||
{cover && coverOK ? (
|
||||
{coverSrc && coverOK ? (
|
||||
<img
|
||||
src={cover}
|
||||
src={coverSrc}
|
||||
alt={title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
@@ -381,7 +236,7 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevChapter}
|
||||
onClick={() => engine.prev()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="上一章"
|
||||
title="上一章"
|
||||
@@ -427,7 +282,7 @@ export function ReaderAudioPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextChapter}
|
||||
onClick={() => engine.next()}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="下一章"
|
||||
title="下一章"
|
||||
@@ -501,18 +356,46 @@ export function ReaderAudioPanel({
|
||||
{sheet === 'chapters' && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
|
||||
className="flex items-center gap-2 border-b px-4 py-3 text-xs font-bold"
|
||||
style={{ borderColor: theme.text + '22' }}
|
||||
>
|
||||
<span>章节({chapters.length})</span>
|
||||
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
|
||||
<span className="shrink-0">章节({chapters.length})</span>
|
||||
{chapterGroups.length > 1 && (
|
||||
<select
|
||||
value={groupIndex}
|
||||
onChange={(e) => jumpToChapterGroup(Number(e.target.value))}
|
||||
aria-label="按区间快速定位章节"
|
||||
title="按区间快速定位章节"
|
||||
className="min-w-0 flex-1 rounded-lg border bg-transparent px-2 py-1 text-2xs font-normal outline-none"
|
||||
style={{ borderColor: theme.text + '33', color: theme.text }}
|
||||
>
|
||||
{chapterGroups.map((group) => (
|
||||
<option
|
||||
key={group.index}
|
||||
value={group.index}
|
||||
style={{ color: '#111827', backgroundColor: '#ffffff' }}
|
||||
>
|
||||
{group.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSheet}
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[45vh] min-h-0">
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex)}
|
||||
rangeChanged={(range) =>
|
||||
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
|
||||
}
|
||||
itemContent={(_i, row) => {
|
||||
const isCurrent = row.i === chapterIndex
|
||||
return (
|
||||
@@ -521,7 +404,7 @@ export function ReaderAudioPanel({
|
||||
onClick={() => {
|
||||
if (row.is_volume) return
|
||||
closeSheet()
|
||||
onSelectChapter(row.i)
|
||||
engine.selectChapter(row.i)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
row.is_volume ? 'font-bold opacity-70' : ''
|
||||
@@ -671,70 +554,6 @@ export function ReaderAudioPanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onError={(e) => {
|
||||
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||
const code = e.currentTarget.error?.code
|
||||
setLoading(false)
|
||||
setAudioError(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget
|
||||
setDur(audio.duration)
|
||||
setLoading(false)
|
||||
audio.playbackRate = speed
|
||||
if (!restoredRef.current) {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
if (initialPos > 0 && (total === 0 || initialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = initialPos
|
||||
setCur(initialPos)
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
setCur(openCredits)
|
||||
}
|
||||
}
|
||||
restoredRef.current = true
|
||||
void audio.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const audio = e.currentTarget
|
||||
const t = audio.currentTime
|
||||
setCur(t)
|
||||
onProgress(t)
|
||||
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||
if (
|
||||
closeCredits > 0 &&
|
||||
!skippedEndRef.current &&
|
||||
Number.isFinite(audio.duration) &&
|
||||
audio.duration > closeCredits + 1 &&
|
||||
t >= audio.duration - closeCredits
|
||||
) {
|
||||
skippedEndRef.current = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
onEnded()
|
||||
}
|
||||
}}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
|
||||
|
||||
@@ -22,8 +24,14 @@ export default function ReaderBookPage() {
|
||||
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
|
||||
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
|
||||
const [sourceType, setSourceType] = useState(0)
|
||||
const [tocExpanded, setTocExpanded] = useState(false)
|
||||
const [introExpanded, setIntroExpanded] = useState(false)
|
||||
// 换源:候选源来自按书名重新搜索的结果
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [pickLoading, setPickLoading] = useState(false)
|
||||
const [candidates, setCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||
|
||||
const loadInfo = () => {
|
||||
if (!bookURL) return
|
||||
@@ -52,6 +60,16 @@ export default function ReaderBookPage() {
|
||||
.catch(() => setChapters([]))
|
||||
}, [bookURL, sourceURL])
|
||||
|
||||
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
|
||||
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
|
||||
useEffect(() => {
|
||||
if (!sourceURL) return
|
||||
readerAPI
|
||||
.listSources()
|
||||
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
|
||||
.catch(() => undefined)
|
||||
}, [sourceURL])
|
||||
|
||||
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
|
||||
useEffect(() => {
|
||||
const tocURL = info?.toc_url
|
||||
@@ -78,7 +96,7 @@ export default function ReaderBookPage() {
|
||||
source_id: originID,
|
||||
origin: sourceURL,
|
||||
origin_name: originName || info?.name || '',
|
||||
origin_type: 0,
|
||||
origin_type: sourceType,
|
||||
book_url: bookURL,
|
||||
latest_chapter: info?.latest_chapter ?? '',
|
||||
},
|
||||
@@ -120,6 +138,66 @@ export default function ReaderBookPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 换源 ──
|
||||
|
||||
// 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。
|
||||
const openSourcePicker = async () => {
|
||||
const key = (info?.name || qName).trim()
|
||||
if (!key) {
|
||||
toast.error('缺少书名,无法换源')
|
||||
return
|
||||
}
|
||||
setPickerOpen(true)
|
||||
setPickLoading(true)
|
||||
setCandidates([])
|
||||
try {
|
||||
const res = await readerAPI.search(key)
|
||||
const list = res.books ?? []
|
||||
const wantAuthor = (info?.author || qAuthor).trim()
|
||||
const hit =
|
||||
list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ??
|
||||
list.find((b) => b.name === key) ??
|
||||
null
|
||||
setCandidates(hit?.origins ?? [])
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败')
|
||||
} finally {
|
||||
setPickLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const bookPath = (origin: ReaderSearchOrigin) =>
|
||||
`/reader/book?${new URLSearchParams({
|
||||
source_url: origin.origin,
|
||||
book_url: origin.book_url,
|
||||
name,
|
||||
author,
|
||||
cover_url: cover,
|
||||
origin_id: origin.source_id,
|
||||
origin_name: origin.origin_name,
|
||||
}).toString()}`
|
||||
|
||||
// 已在书架:调用换源接口(服务端保留进度、清空旧源目录缓存);
|
||||
// 未在书架:这本书还没绑定书源,直接按所选源打开详情页即可。
|
||||
const applyOrigin = async (origin: ReaderSearchOrigin) => {
|
||||
setPickerOpen(false)
|
||||
if (!shelfBook) {
|
||||
navigate(bookPath(origin))
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await readerAPI.switchOrigin(shelfBook.id, origin)
|
||||
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
|
||||
// URL 上的旧源地址已失效,替换成新源地址,页面据此重新拉详情与目录
|
||||
navigate(bookPath(origin), { replace: true })
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookURL) {
|
||||
return (
|
||||
<div className="mx-auto px-6 py-24 text-center text-sm text-[var(--app-muted)]">缺少书籍参数</div>
|
||||
@@ -151,13 +229,7 @@ export default function ReaderBookPage() {
|
||||
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
|
||||
<div className="mt-6 flex gap-5">
|
||||
<div className="h-40 w-28 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
{cover ? (
|
||||
<img src={cover} alt={name} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={24} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={cover} alt={name} iconSize={24} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="font-display text-xl text-ink-600">{name || '未知书名'}</h1>
|
||||
@@ -209,6 +281,15 @@ export default function ReaderBookPage() {
|
||||
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
|
||||
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSourcePicker}
|
||||
disabled={busy}
|
||||
className="btn-outline shrink-0 px-3 text-xs disabled:opacity-50"
|
||||
title="换源"
|
||||
>
|
||||
<ArrowLeftRight size={13} className="mr-1 inline" /> 换源
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 目录 */}
|
||||
@@ -245,6 +326,18 @@ export default function ReaderBookPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pickerOpen && (
|
||||
<SourcePickerDialog
|
||||
title={name || '这本书'}
|
||||
origins={candidates}
|
||||
current={{ originURL: sourceURL, bookURL }}
|
||||
loading={pickLoading}
|
||||
emptyHint="按书名重搜后没有找到其它书源"
|
||||
onPick={applyOrigin}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { rememberImageSize } from '../../utils/comicSpread'
|
||||
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
|
||||
import type { ComicImageFit } from '../../stores/readerSettings'
|
||||
|
||||
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
|
||||
//
|
||||
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
|
||||
// 这里只负责把它们并排摆好、各自撑满视口高度。
|
||||
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
|
||||
|
||||
interface ReaderComicProps {
|
||||
images: string[]
|
||||
@@ -14,40 +23,118 @@ interface ReaderComicProps {
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
}
|
||||
|
||||
function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) {
|
||||
function ComicImage({
|
||||
src,
|
||||
theme,
|
||||
fit = false,
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
|
||||
fit?: boolean
|
||||
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
|
||||
half?: boolean
|
||||
// imageFit:滚动模式的显示尺寸档位。
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
// 换章后 src 变了要重新回到 loading:否则上一章加载失败的第 N 张会一直
|
||||
// 显示「图片加载失败」,而 key 不变(滚动模式按序号做 key)不会重挂载。
|
||||
useEffect(() => {
|
||||
setState('loading')
|
||||
}, [src])
|
||||
|
||||
const loaded = state === 'ok'
|
||||
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
|
||||
const sizing = fit
|
||||
? {
|
||||
className: 'block max-h-full w-auto max-w-full object-contain',
|
||||
style: loaded ? {} : { minHeight: '10rem' },
|
||||
}
|
||||
: comicImageSizing(imageFit, viewportHeight, loaded)
|
||||
|
||||
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
|
||||
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
|
||||
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
|
||||
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
|
||||
style={{
|
||||
backgroundColor: theme.bg,
|
||||
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
|
||||
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
|
||||
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
|
||||
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
|
||||
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
|
||||
setState('ok')
|
||||
}}
|
||||
onError={() => setState('error')}
|
||||
className={sizing.className}
|
||||
style={sizing.style}
|
||||
/>
|
||||
{state === 'loading' && (
|
||||
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
style={{ color: theme.text }}
|
||||
>
|
||||
<Loader2 className="animate-spin opacity-50" size={22} />
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' ? (
|
||||
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
|
||||
{state === 'error' && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-xs opacity-60"
|
||||
style={{ color: theme.text }}
|
||||
>
|
||||
图片加载失败
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onError={() => setState('error')}
|
||||
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
// 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
|
||||
// root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
|
||||
// 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
|
||||
const [viewportHeight, setViewportHeight] = useState(0)
|
||||
|
||||
const selfSized = isSelfSizedComicFit(imageFit)
|
||||
useEffect(() => {
|
||||
// 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
|
||||
if (mode !== 'scroll' || !selfSized) return
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const update = () => setViewportHeight(el.clientHeight)
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [mode, selfSized])
|
||||
|
||||
// 滚动模式:恢复进度(图片序号)并上报当前图
|
||||
useEffect(() => {
|
||||
@@ -70,6 +157,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
restoredRef.current = false
|
||||
}, [mode])
|
||||
|
||||
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
|
||||
useSmoothWheelScroll(scrollRef, mode === 'scroll')
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
@@ -79,11 +169,16 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
|
||||
const idx = Math.min(Math.max(page, 0), images.length - 1)
|
||||
// 翻页模式:翻页由外层点击区驱动(page 语义 = 图片序号)。
|
||||
// 双页铺开时外层按「屏」给好成员(spread),这里只负责并排摆开。
|
||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
||||
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||
const paired = idxs.length > 1
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -125,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComicImage src={src} theme={theme} />
|
||||
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
|
||||
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
|
||||
{i + 1} / {images.length}
|
||||
</p>
|
||||
|
||||
@@ -1,23 +1,134 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileUp,
|
||||
FolderOpen,
|
||||
Headphones,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderBook } from '../../api/reader'
|
||||
import { BookGroupBar } from '../../components/BookGroupBar'
|
||||
import { BookGroupPickerDialog } from '../../components/BookGroupPickerDialog'
|
||||
import { ManageBookGroupsDialogView } from '../../components/ManageBookGroupsDialogView'
|
||||
import { confirmAction } from '../../components/confirmAction'
|
||||
import { useBookGroups } from '../../hooks/useBookGroups'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { buildBookGroupTabs, filterBooksByGroup, groupIdOfBook, ALL_GROUP_ID } from '../../utils/readerBookGroups'
|
||||
import { BookshelfCard } from './BookshelfCard'
|
||||
import { BookshelfSettingsDialog } from './BookshelfSettingsDialog'
|
||||
import { SHELF_SORT_OPTIONS, sortShelfBooks } from './bookshelfModel'
|
||||
import { ReaderModeSwitch } from './ReaderModeSwitch'
|
||||
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
|
||||
|
||||
// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。
|
||||
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。
|
||||
// 结构仿 legado 书架:顶部操作区 + 分组栏 + 网格/列表/紧凑列表三套布局 +
|
||||
// 可配置排序与显示项。布局、排序、显示项存在 readerSettings store;
|
||||
// 分组按用户存在服务端(见 utils/readerBookGroups.ts、hooks/useBookGroups.ts)。
|
||||
|
||||
// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。
|
||||
// total_chapter_num 为 0 表示目录尚未缓存,无法计算。
|
||||
function unreadChapters(book: ReaderBook): number | null {
|
||||
if (book.total_chapter_num <= 0) return null
|
||||
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||
// 网格列数 → Tailwind 栅格类。窄屏保留 2–3 列兜底,避免固定列数在手机上挤成一团。
|
||||
const GRID_COLUMNS_CLASS: Record<number, string> = {
|
||||
0: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-2 sm:grid-cols-3',
|
||||
4: 'grid-cols-3 sm:grid-cols-4',
|
||||
5: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5',
|
||||
6: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6',
|
||||
}
|
||||
|
||||
/** 工具栏下拉菜单:自管开合、点外部或 Esc 关闭。 */
|
||||
function ToolbarMenu({
|
||||
trigger,
|
||||
title,
|
||||
disabled = false,
|
||||
width = 'w-44',
|
||||
children,
|
||||
}: {
|
||||
trigger: ReactNode
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
width?: string
|
||||
children: (close: () => void) => ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute right-0 z-30 mt-1.5 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl ${width}`}
|
||||
>
|
||||
{children(() => setOpen(false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
|
||||
@@ -27,26 +138,42 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
|
||||
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [manageGroupsOpen, setManageGroupsOpen] = useState(false)
|
||||
const [groupPickerBook, setGroupPickerBook] = useState<ReaderBook | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 下拉菜单:点外部或按 Esc 关闭
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
const shelfLayout = useReaderSettingsStore((s) => s.shelfLayout)
|
||||
const shelfSort = useReaderSettingsStore((s) => s.shelfSort)
|
||||
const setShelfSort = useReaderSettingsStore((s) => s.setShelfSort)
|
||||
const shelfGridColumns = useReaderSettingsStore((s) => s.shelfGridColumns)
|
||||
const shelfShowUnread = useReaderSettingsStore((s) => s.shelfShowUnread)
|
||||
const shelfShowUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime)
|
||||
|
||||
const sortedBooks = useMemo(() => (books ? sortShelfBooks(books, shelfSort) : null), [books, shelfSort])
|
||||
const sortLabel = SHELF_SORT_OPTIONS.find((o) => o.value === shelfSort)?.label ?? '最近阅读'
|
||||
|
||||
// ── 书架分组(仿影视模块的媒体库标签)──
|
||||
const bookGroups = useBookGroups()
|
||||
const groupTabs = useMemo(
|
||||
() => buildBookGroupTabs(books ?? [], bookGroups.groups),
|
||||
[books, bookGroups.groups],
|
||||
)
|
||||
// 先按分组过滤,再按当前排序方式排;两者互不影响。
|
||||
const visibleBooks = useMemo(
|
||||
() => (sortedBooks ? filterBooksByGroup(sortedBooks, bookGroups.groups, bookGroups.selectedGroupId) : null),
|
||||
[sortedBooks, bookGroups.groups, bookGroups.selectedGroupId],
|
||||
)
|
||||
// 管理弹窗里每个分组的书籍数只统计书架上真实存在的书。
|
||||
const groupBookCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
const available = new Set((books ?? []).map((b) => b.id))
|
||||
for (const group of bookGroups.groups) {
|
||||
counts[group.name] = group.book_ids.filter((id) => available.has(id)).length
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [menuOpen])
|
||||
return counts
|
||||
}, [books, bookGroups.groups])
|
||||
|
||||
const load = () => {
|
||||
setError('')
|
||||
@@ -113,14 +240,52 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
}
|
||||
}
|
||||
|
||||
// 更新目录(对应 legado 书架的「更新目录」):重抓全部网络书籍的目录,刷出最新章节。
|
||||
const refreshToc = async () => {
|
||||
setRefreshing(true)
|
||||
const toastId = toast.loading('正在更新目录…')
|
||||
try {
|
||||
const res = await readerAPI.refreshBooksToc()
|
||||
load()
|
||||
if (res.total === 0) {
|
||||
toast.success('没有需要更新的网络书籍', { id: toastId })
|
||||
} else if (res.failed > 0) {
|
||||
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { id: toastId, icon: '⚠️' })
|
||||
} else {
|
||||
toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId })
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '更新目录失败', {
|
||||
id: toastId,
|
||||
})
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const assignBookToGroup = async (book: ReaderBook, groupName: string) => {
|
||||
setGroupPickerBook(null)
|
||||
await bookGroups.assignBook(book.id, groupName)
|
||||
}
|
||||
|
||||
const inSpecificGroup = bookGroups.selectedGroupId !== ALL_GROUP_ID
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!embedded && <ReaderModeSwitch />}
|
||||
|
||||
{/* 标题右侧一个下拉入口,收纳全部书架操作 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="shrink-0 font-display text-2xl text-ink-600">书架</h1>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="shrink-0 font-display text-2xl text-ink-600">
|
||||
书架
|
||||
{visibleBooks !== null && visibleBooks.length > 0 && (
|
||||
<span className="ml-2 align-middle text-xs font-normal text-[var(--app-muted)]">
|
||||
{visibleBooks.length} 本
|
||||
{inSpecificGroup && books !== null && <span className="ml-1">/ 共 {books.length} 本</span>}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -131,81 +296,148 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
if (f) void upload(f)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 排序(对应 legado 书架设置的排序项) */}
|
||||
<ToolbarMenu
|
||||
title="排序方式"
|
||||
trigger={
|
||||
<>
|
||||
<SlidersHorizontal size={13} />
|
||||
<span className="hidden sm:inline">{sortLabel}</span>
|
||||
<ChevronDown size={13} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{SHELF_SORT_OPTIONS.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
icon={shelfSort === option.value ? <Check size={13} /> : <span className="w-[13px]" />}
|
||||
onClick={() => {
|
||||
setShelfSort(option.value)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ToolbarMenu>
|
||||
|
||||
{/* 布局 / 显示设置 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
title="书架操作"
|
||||
aria-label="书架设置"
|
||||
title="书架设置:布局、排序、显示项"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)]"
|
||||
>
|
||||
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
|
||||
{uploading !== null ? `上传中 ${uploading}%` : '管理'}
|
||||
<ChevronDown size={13} className={`transition-transform ${menuOpen ? 'rotate-180' : ''}`} />
|
||||
{shelfLayout === 'grid' ? <LayoutGrid size={14} /> : shelfLayout === 'list' ? <List size={14} /> : <Rows3 size={14} />}
|
||||
<Settings2 size={13} />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-1.5 w-40 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="上传 TXT / EPUB 到服务器阅读"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
fileRef.current?.click()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<FileUp size={13} /> 本地导入
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上已有的 TXT / EPUB 文件导入(原地引用,不复制)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('book')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<FolderOpen size={13} /> 服务器导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上的一个目录导入为有声书(含 .strm 播放指针)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('audio')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<Headphones size={13} /> 有声书导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<Link
|
||||
to="/reader/sources"
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
<Settings2 size={13} /> 书源管理
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* 书架操作 */}
|
||||
<ToolbarMenu
|
||||
title="书架操作"
|
||||
width="w-44"
|
||||
disabled={uploading !== null}
|
||||
trigger={
|
||||
<>
|
||||
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
|
||||
<span className="hidden sm:inline">{uploading !== null ? `上传中 ${uploading}%` : '管理'}</span>
|
||||
<ChevronDown size={13} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<FileUp size={13} />}
|
||||
disabled={uploading !== null}
|
||||
onClick={() => {
|
||||
close()
|
||||
fileRef.current?.click()
|
||||
}}
|
||||
>
|
||||
本地导入
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<FolderOpen size={13} />}
|
||||
disabled={uploading !== null}
|
||||
onClick={() => {
|
||||
close()
|
||||
setPicker('book')
|
||||
}}
|
||||
>
|
||||
服务器导入
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<Headphones size={13} />}
|
||||
disabled={uploading !== null}
|
||||
onClick={() => {
|
||||
close()
|
||||
setPicker('audio')
|
||||
}}
|
||||
>
|
||||
有声书导入
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={refreshing ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
disabled={refreshing}
|
||||
onClick={() => {
|
||||
close()
|
||||
void refreshToc()
|
||||
}}
|
||||
>
|
||||
更新目录
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal size={13} />}
|
||||
onClick={() => {
|
||||
close()
|
||||
setSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
书架设置
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<Settings2 size={13} />}
|
||||
onClick={() => {
|
||||
close()
|
||||
setManageGroupsOpen(true)
|
||||
}}
|
||||
>
|
||||
管理分组
|
||||
</MenuItem>
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<Link
|
||||
to="/reader/sources"
|
||||
role="menuitem"
|
||||
onClick={close}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
<Settings2 size={13} /> 书源管理
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</ToolbarMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{books !== null && books.length > 0 && (
|
||||
<BookGroupBar
|
||||
tabs={groupTabs}
|
||||
selectedGroupId={bookGroups.selectedGroupId}
|
||||
onSelect={bookGroups.setSelectedGroupId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{books === null && !error && (
|
||||
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={22} />
|
||||
@@ -253,78 +485,75 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
</div>
|
||||
)}
|
||||
|
||||
{books !== null && books.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-6 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{books.map((book) => {
|
||||
const unread = unreadChapters(book)
|
||||
return (
|
||||
<div key={book.id} className="group">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/reader/view/${book.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
}
|
||||
}}
|
||||
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={22} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
|
||||
{book.is_local && (
|
||||
<span className="absolute bottom-1 left-1 flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||
<HardDrive size={10} /> 本地
|
||||
</span>
|
||||
)}
|
||||
{unread !== null && (
|
||||
<span
|
||||
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||
className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${
|
||||
unread > 0 ? 'bg-rose-500/90' : 'bg-black/55'
|
||||
}`}
|
||||
>
|
||||
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移出书架:${book.name}`}
|
||||
title="移出书架"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void removeFromShelf(book)
|
||||
}}
|
||||
className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
|
||||
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{books !== null && books.length > 0 && visibleBooks !== null && visibleBooks.length === 0 && (
|
||||
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-10 text-center">
|
||||
<p className="text-sm font-bold text-[var(--app-text)]">这个分组还没有书</p>
|
||||
<p className="mt-1 text-xs text-[var(--app-muted)]">
|
||||
在书籍卡片上点「移到分组」把书放进来,或切到「未分组」看看还没归组的书
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleBooks !== null && visibleBooks.length > 0 && (
|
||||
shelfLayout === 'grid' ? (
|
||||
<div className={`grid gap-x-4 gap-y-6 ${GRID_COLUMNS_CLASS[shelfGridColumns] ?? GRID_COLUMNS_CLASS[0]}`}>
|
||||
{visibleBooks.map((book) => (
|
||||
<BookshelfCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
layout="grid"
|
||||
showUnread={shelfShowUnread}
|
||||
showUpdateTime={shelfShowUpdateTime}
|
||||
onOpen={() => navigate(`/reader/view/${book.id}`)}
|
||||
onPickGroup={() => setGroupPickerBook(book)}
|
||||
onRemove={() => void removeFromShelf(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visibleBooks.map((book) => (
|
||||
<BookshelfCard
|
||||
key={book.id}
|
||||
book={book}
|
||||
layout={shelfLayout}
|
||||
showUnread={shelfShowUnread}
|
||||
showUpdateTime={shelfShowUpdateTime}
|
||||
groupName={groupIdOfBook(bookGroups.groups, book.id)}
|
||||
onOpen={() => navigate(`/reader/view/${book.id}`)}
|
||||
onPickGroup={() => setGroupPickerBook(book)}
|
||||
onRemove={() => void removeFromShelf(book)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{settingsOpen && <BookshelfSettingsDialog onClose={() => setSettingsOpen(false)} />}
|
||||
|
||||
{manageGroupsOpen && (
|
||||
<ManageBookGroupsDialogView
|
||||
groups={bookGroups.groups}
|
||||
bookCounts={groupBookCounts}
|
||||
saving={bookGroups.saving}
|
||||
onCreate={bookGroups.createGroup}
|
||||
onRename={bookGroups.renameGroup}
|
||||
onRemove={bookGroups.removeGroup}
|
||||
onReorder={bookGroups.reorderGroups}
|
||||
onClose={() => setManageGroupsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groupPickerBook && (
|
||||
<BookGroupPickerDialog
|
||||
book={groupPickerBook}
|
||||
groups={bookGroups.groups}
|
||||
currentGroupId={groupIdOfBook(bookGroups.groups, groupPickerBook.id)}
|
||||
onPick={(groupName) => void assignBookToGroup(groupPickerBook, groupName)}
|
||||
onClose={() => setGroupPickerBook(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<ServerFilePickerDialog
|
||||
mode={picker === 'audio' ? 'dir' : 'file'}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Pause, Play, X } from 'lucide-react'
|
||||
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 全局听书迷你条。
|
||||
//
|
||||
// 播放引擎是常驻单例(见 readerAudioEngine.ts),离开阅读页后音频仍在播;这个条
|
||||
// 保证在其他页面也能暂停/回到阅读,否则用户会「听到声音但找不到控制」。
|
||||
// 面板所在的阅读页内部不显示它(那里有完整播放面板),避免两套控制打架。
|
||||
export function ReaderMiniPlayer() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const track = useReaderAudioStore((s) => s.track)
|
||||
const bookId = useReaderAudioStore((s) => s.bookId)
|
||||
const title = useReaderAudioStore((s) => s.chapterTitle)
|
||||
const bookName = useReaderAudioStore((s) => s.bookName)
|
||||
const cover = useReaderAudioStore((s) => s.cover)
|
||||
const status = useReaderAudioStore((s) => s.status)
|
||||
|
||||
const onReaderView = bookId !== '' && location.pathname === `/reader/view/${bookId}`
|
||||
if (!track || !bookId || onReaderView || status === 'idle') return null
|
||||
|
||||
const engine = getReaderAudioEngine()
|
||||
const playing = status === 'playing'
|
||||
const loading = status === 'loading'
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-3 z-[60] flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]/95 p-2 shadow-2xl backdrop-blur-md lg:inset-x-auto lg:right-6 lg:w-96"
|
||||
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 3.75rem)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/reader/view/${bookId}`)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
title="回到阅读页"
|
||||
>
|
||||
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-brand-soft)]">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-bold text-[var(--app-text)]">{title || bookName}</span>
|
||||
<span className="block truncate text-[10px] text-[var(--app-muted)]">{bookName || 'MeBox 听书'}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.toggle()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm"
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : playing ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => engine.stop()}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
aria-label="停止播放"
|
||||
title="停止播放"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, 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, ListFilter, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
|
||||
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { splitKindTags } from '../../utils/kindTags'
|
||||
import { SearchScopeDialog } from './SearchScopeDialog'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
|
||||
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
|
||||
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
|
||||
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
|
||||
|
||||
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
|
||||
function shelfBookKey(name: string, author: string): string {
|
||||
const n = name.trim()
|
||||
const a = author.trim()
|
||||
return a ? `${n}|${a}` : n
|
||||
}
|
||||
|
||||
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
|
||||
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
|
||||
if (shelf.has(shelfBookKey(book.name, book.author))) return true
|
||||
if (book.book_url && shelf.has(book.book_url)) return true
|
||||
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
|
||||
}
|
||||
|
||||
export default function ReaderSearchPage() {
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const [key, setKey] = useState(() => params.get('key') ?? '')
|
||||
const [searching, setSearching] = useState(false)
|
||||
// 换源:点「N 源可换」后选择用哪个源打开这本书
|
||||
const [picker, setPicker] = useState<ReaderSearchBook | null>(null)
|
||||
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
const [showSkipped, setShowSkipped] = useState(false)
|
||||
const [adding, setAdding] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
|
||||
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
|
||||
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
|
||||
const [sources, setSources] = useState<ReaderSource[]>([])
|
||||
const [showScope, setShowScope] = useState(false)
|
||||
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
|
||||
const activeScopeIds = useMemo(
|
||||
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
|
||||
[scopeIds, enabledSources],
|
||||
)
|
||||
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
|
||||
// 避免已保存子集在加载瞬间被错标成「全部书源」。
|
||||
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
|
||||
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
|
||||
|
||||
useEffect(() => {
|
||||
readerAPI.listSources().then(setSources).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
|
||||
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
readerAPI
|
||||
.listBooks()
|
||||
.then((books) => {
|
||||
const keys = new Set<string>()
|
||||
for (const b of books) {
|
||||
keys.add(shelfBookKey(b.name, b.author))
|
||||
if (b.book_url) keys.add(b.book_url)
|
||||
}
|
||||
setShelfKeys(keys)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
|
||||
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
|
||||
// 且刚加进书架的书也会立刻从列表消失。
|
||||
const visibleBooks = useMemo(
|
||||
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
|
||||
[books, shelfKeys],
|
||||
)
|
||||
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
|
||||
|
||||
const doSearch = async () => {
|
||||
const kw = key.trim()
|
||||
if (!kw || searching) return
|
||||
setSearching(true)
|
||||
setShowSkipped(false)
|
||||
try {
|
||||
const res = await readerAPI.search(kw)
|
||||
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
|
||||
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
|
||||
const res = await readerAPI.search(kw, scopeIds)
|
||||
setBooks(res.books ?? [])
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
@@ -47,12 +117,20 @@ export default function ReaderSearchPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
||||
const addToShelf = async (book: ReaderSearchBook) => {
|
||||
const origin = book.origins[0]
|
||||
if (!origin) return
|
||||
setAdding(book.book_url)
|
||||
try {
|
||||
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
|
||||
toast.success(`《${book.name}》已加入书架`)
|
||||
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
|
||||
setShelfKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(shelfBookKey(book.name, book.author))
|
||||
if (origin.book_url) next.add(origin.book_url)
|
||||
return next
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
|
||||
toast.error(msg)
|
||||
@@ -61,8 +139,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({
|
||||
@@ -109,6 +186,22 @@ export default function ReaderSearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowScope(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
|
||||
>
|
||||
<ListFilter size={13} />
|
||||
搜索范围
|
||||
<span className="text-brand-600">{scopeLabel}</span>
|
||||
</button>
|
||||
{scopeAll && enabledSources.length > 0 && (
|
||||
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searching && (
|
||||
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={22} />
|
||||
@@ -116,45 +209,56 @@ export default function ReaderSearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && books !== null && (
|
||||
{!searching && visibleBooks !== null && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{books.map((book) => (
|
||||
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
|
||||
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||||
已隐藏 {hiddenOnShelf} 本已在书架里的书
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.length === 0 && (
|
||||
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
|
||||
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
|
||||
</p>
|
||||
)}
|
||||
{visibleBooks.map((book) => (
|
||||
<div
|
||||
key={`${book.name}|${book.author}`}
|
||||
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
|
||||
>
|
||||
<button type="button" onClick={() => openBook(book)} className="shrink-0">
|
||||
<div className="h-24 w-16 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={18} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={18} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button type="button" onClick={() => openBook(book)} className="text-left">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">
|
||||
{book.name}
|
||||
{book.origins.length > 1 && (
|
||||
<span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||||
{book.origins.length} 源可换
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
|
||||
{book.author || '佚名'}
|
||||
{book.kind ? ` · ${book.kind}` : ''}
|
||||
{book.word_count ? ` · ${book.word_count}` : ''}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => openBook(book)} className="min-w-0 text-left">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">{book.name}</p>
|
||||
</button>
|
||||
{book.origins.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicker(book)}
|
||||
title="换源"
|
||||
className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600 transition hover:bg-brand-500/20"
|
||||
>
|
||||
{book.origins.length} 源可换
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-[var(--app-muted)]">
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{splitKindTags(book.kind).map((k) => (
|
||||
<span key={k} className="shrink-0 rounded bg-brand-500/10 px-1 py-0.5 text-2xs font-bold text-brand-600">
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
{book.word_count && (
|
||||
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||
{book.word_count}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">最新:{book.latest_chapter || '未知'}</p>
|
||||
{book.intro && <p className="mt-1 line-clamp-2 text-xs text-[var(--app-subtle)]">{book.intro}</p>}
|
||||
@@ -207,6 +311,31 @@ export default function ReaderSearchPage() {
|
||||
输入关键词开始搜索 · 需要 <Link to="/reader/sources" className="text-brand-600 hover:underline">先导入书源</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<SourcePickerDialog
|
||||
title={picker.name}
|
||||
origins={picker.origins}
|
||||
onPick={(origin) => {
|
||||
const book = picker
|
||||
setPicker(null)
|
||||
openBook(book, origin)
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScope && (
|
||||
<SearchScopeDialog
|
||||
sources={sources}
|
||||
selectedIds={scopeIds}
|
||||
onConfirm={(ids) => {
|
||||
setSearchScopeIds(ids)
|
||||
setShowScope(false)
|
||||
}}
|
||||
onClose={() => setShowScope(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
BookOpen,
|
||||
Columns2,
|
||||
LayoutList,
|
||||
ListEnd,
|
||||
Loader2,
|
||||
@@ -13,12 +15,18 @@ import {
|
||||
ScrollText,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
|
||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
|
||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
|
||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||
import { ReaderComic } from './ReaderComic'
|
||||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||||
import { getReaderAudioEngine } from './readerAudioEngine'
|
||||
|
||||
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
|
||||
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||
@@ -28,6 +36,11 @@ const COLUMN_GAP = 48
|
||||
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
|
||||
const IMG_MARK = '[img]'
|
||||
|
||||
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
|
||||
const AUDIO_SAVE_INTERVAL_MS = 5_000
|
||||
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
|
||||
const COMIC_SAVE_INTERVAL_MS = 2_000
|
||||
|
||||
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||
const MENU_SHIFT = 'transition-transform duration-200'
|
||||
|
||||
@@ -41,6 +54,15 @@ const WHEEL_TURN_COOLDOWN = 220
|
||||
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
|
||||
const WHEEL_GESTURE_GAP = 180
|
||||
|
||||
/**
|
||||
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
|
||||
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
|
||||
*/
|
||||
const COMIC_SPREAD_MIN_WIDTH = 900
|
||||
|
||||
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
|
||||
const NO_IMAGES: string[] = []
|
||||
|
||||
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||
if (i !== -1) return i
|
||||
@@ -64,6 +86,13 @@ export default function ReaderViewPage() {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
||||
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
|
||||
const [tocGroupIndex, setTocGroupIndex] = useState(0)
|
||||
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
|
||||
const [switchOpen, setSwitchOpen] = useState(false)
|
||||
const [switchLoading, setSwitchLoading] = useState(false)
|
||||
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
const topBarRef = useRef<HTMLDivElement>(null)
|
||||
const [menuInset, setMenuInset] = useState(0)
|
||||
@@ -73,6 +102,8 @@ export default function ReaderViewPage() {
|
||||
const wheelLastEventRef = useRef(0)
|
||||
const wheelLastTurnRef = useRef(0)
|
||||
const wheelSwipeLockedRef = useRef(false)
|
||||
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
|
||||
const tocRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
// 分页状态
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
@@ -92,6 +123,47 @@ export default function ReaderViewPage() {
|
||||
const pendingPosRef = useRef(0)
|
||||
const pendingEndRef = useRef(false)
|
||||
|
||||
// ── 漫画双页铺开(桌面端) ──
|
||||
// 窗口宽度决定双页是否可用:偏好在 settings 里(用户可关),但窗口不够宽时
|
||||
// 即使开着也退回单页,所以这里额外跟一个窗口宽度。
|
||||
const [windowWidth, setWindowWidth] = useState(() => window.innerWidth)
|
||||
useEffect(() => {
|
||||
const onResize = () => setWindowWidth(window.innerWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
|
||||
const comicDoublePage =
|
||||
contentType === 'image' &&
|
||||
settings.pageMode === 'page' &&
|
||||
settings.comicDoublePage &&
|
||||
windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
// 分组依赖每张图的原始宽高(横跨两页的宽图要独占一屏),探测窗口跟着当前页走
|
||||
const { spreads: comicSpreads, spreadIndexOf: comicSpreadIndexOf } = useComicSpreads(
|
||||
comicImages,
|
||||
comicDoublePage,
|
||||
comicPage,
|
||||
)
|
||||
/** 当前是第几「屏」。进度仍按图片序号记,屏序号只用于翻页与渲染。 */
|
||||
const comicSpreadIndex =
|
||||
comicDoublePage && comicSpreads.length > 0
|
||||
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
||||
: 0
|
||||
|
||||
// ── 漫画滚动模式的图片显示尺寸 ──
|
||||
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
||||
// 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
|
||||
const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
|
||||
/** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
|
||||
const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
|
||||
/**
|
||||
* 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
|
||||
* 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
|
||||
* 900px 的列会把它们再压回去。
|
||||
*/
|
||||
const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
|
||||
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -123,11 +195,28 @@ export default function ReaderViewPage() {
|
||||
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
|
||||
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
|
||||
let idx = firstReadableIndex(chs)
|
||||
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
|
||||
const explicitChapter = Number.isInteger(qChapter) && !!chs[qChapter] && !chs[qChapter].is_volume
|
||||
if (explicitChapter) idx = qChapter
|
||||
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||
idx = b.dur_chapter_index
|
||||
}
|
||||
pendingPosRef.current = b.dur_chapter_pos ?? 0
|
||||
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
|
||||
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
|
||||
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
|
||||
const live = useReaderAudioStore.getState()
|
||||
if (
|
||||
!explicitChapter &&
|
||||
live.bookId === b.id &&
|
||||
live.track !== '' &&
|
||||
(live.status === 'playing' || live.status === 'loading' || live.status === 'paused') &&
|
||||
live.chapterIndex !== idx &&
|
||||
chs[live.chapterIndex]
|
||||
) {
|
||||
idx = live.chapterIndex
|
||||
// 位置由引擎自己掌握,服务端那份属于上一章,别写过去
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
setChapterIndex(idx)
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || '加载失败')
|
||||
@@ -139,7 +228,7 @@ export default function ReaderViewPage() {
|
||||
cancelled = true
|
||||
contentCache.current.clear()
|
||||
}
|
||||
}, [bookId])
|
||||
}, [bookId, reloadKey])
|
||||
|
||||
// ── 加载章节正文(带缓存与下一章预取) ──
|
||||
useEffect(() => {
|
||||
@@ -165,13 +254,32 @@ export default function ReaderViewPage() {
|
||||
setComicPage(0)
|
||||
setCurrentImage(0)
|
||||
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
|
||||
const savedPos = Math.max(0, Math.floor(pendingPosRef.current))
|
||||
if (ct.type !== 'text') {
|
||||
setRestorePos(pendingPosRef.current)
|
||||
setRestorePos(savedPos)
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
// 进度上报(pos 保留原值,排版完成后才被消费清零)
|
||||
// 听书:把这一章交给常驻播放引擎。引擎是全局单例,离开本页也不会被卸载,
|
||||
// 因此换章/离开路由/息屏都继续播(面板卸载不再掐播放)。
|
||||
if (ct.type === 'audio' && ct.tracks && ct.tracks.length > 0) {
|
||||
getReaderAudioEngine().loadChapter({
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
cover: book.cover_url ?? '',
|
||||
chapters,
|
||||
chapterIndex,
|
||||
track: ct.tracks[0],
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: book.open_credits ?? 0,
|
||||
closeCredits: book.close_credits ?? 0,
|
||||
initialPos: savedPos,
|
||||
})
|
||||
}
|
||||
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
|
||||
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
|
||||
// 刚恢复的进度又写成 0,退出重进就从头开始。
|
||||
readerAPI
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
|
||||
.catch(() => undefined)
|
||||
// 预取下一章
|
||||
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||
@@ -239,23 +347,36 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [content, settings.pageMode])
|
||||
|
||||
// ── 进度保存(翻页 / 滚动) ──
|
||||
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
|
||||
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
|
||||
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
|
||||
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
|
||||
|
||||
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
|
||||
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
|
||||
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
|
||||
const savePos = useCallback(
|
||||
(pos: number) => {
|
||||
if (!book || chapterIndex === null) return
|
||||
const ch = chapters[chapterIndex]
|
||||
readerAPI
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos, chapter_title: ch?.title ?? '' })
|
||||
.saveProgress(book.id, {
|
||||
chapter_index: chapterIndex,
|
||||
pos: Math.max(0, Math.floor(pos)),
|
||||
chapter_title: ch?.title ?? '',
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[book, chapterIndex, chapters],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.pageMode !== 'page' || content === null || chapterIndex === null) return
|
||||
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
|
||||
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
|
||||
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
|
||||
const t = setTimeout(() => savePos(page), 1500)
|
||||
return () => clearTimeout(t)
|
||||
}, [page, content, chapterIndex, settings.pageMode, savePos])
|
||||
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
|
||||
|
||||
// 漫画单页进度保存
|
||||
useEffect(() => {
|
||||
@@ -264,12 +385,13 @@ export default function ReaderViewPage() {
|
||||
return () => clearTimeout(t)
|
||||
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
|
||||
|
||||
// 音频/漫画滚动:节流进度保存
|
||||
// 音频/漫画滚动:节流进度保存(听书 5s、漫画 2s)
|
||||
const throttledMediaSave = useCallback(
|
||||
(pos: number) => {
|
||||
if (!book || chapterIndex === null) return
|
||||
const now = Date.now()
|
||||
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
|
||||
const interval = contentType === 'audio' ? AUDIO_SAVE_INTERVAL_MS : COMIC_SAVE_INTERVAL_MS
|
||||
if (now - lastMediaSaveRef.current < interval) return
|
||||
lastMediaSaveRef.current = now
|
||||
savePos(pos)
|
||||
},
|
||||
@@ -315,31 +437,109 @@ export default function ReaderViewPage() {
|
||||
setChapterIndex(idx)
|
||||
}, [chapters.length])
|
||||
|
||||
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
|
||||
const hasNextAudioChapter = useMemo(() => {
|
||||
if (chapterIndex === null) return false
|
||||
for (let i = chapterIndex + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}, [chapterIndex, chapters])
|
||||
// ── 目录面板的分段跳转(对齐听书面板的区间下拉) ──
|
||||
// 上千章的目录按 100 章一组切开,顶部下拉选区间即整段跳转(见 utils/chapterGroups.ts)。
|
||||
// 目录不足一组(≤100 章)时不显示下拉——一屏能扫完,多一个控件只是噪声。
|
||||
const tocGroups = useMemo(() => buildChapterGroups(chapters.length), [chapters.length])
|
||||
|
||||
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
|
||||
const saveAudioCredits = useCallback(
|
||||
(open: number, close: number) => {
|
||||
const jumpToTocGroup = useCallback(
|
||||
(next: number) => {
|
||||
const group = tocGroups[next]
|
||||
if (!group) return
|
||||
setTocGroupIndex(next)
|
||||
tocRef.current?.scrollToIndex({ index: group.start, align: 'start' })
|
||||
},
|
||||
[tocGroups],
|
||||
)
|
||||
|
||||
// 面板开关:打开目录时把区间下拉对齐到当前章节所在组。上次可能停在第 3 组,
|
||||
// 续读已到第 12 组,沿用旧值会显示错误的区间。
|
||||
const openPanel = useCallback(
|
||||
(next: 'none' | 'toc' | 'style') => {
|
||||
if (panel === next) {
|
||||
setPanel('none')
|
||||
return
|
||||
}
|
||||
if (next === 'toc') setTocGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
|
||||
setPanel(next)
|
||||
},
|
||||
[chapterIndex, chapters.length, panel],
|
||||
)
|
||||
|
||||
// 听书:引擎自己也可能切章(播完自动下一章、锁屏/耳机按键上一章下一章),把它的
|
||||
// 当前章节回写进本页,目录与菜单才不会停在旧章节上。只认本书,避免串到别的书。
|
||||
const audioChapterIndex = useReaderAudioStore((s) =>
|
||||
book && s.bookId === book.id ? s.chapterIndex : null,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (contentType !== 'audio' || audioChapterIndex === null) return
|
||||
if (audioChapterIndex === chapterIndex) return
|
||||
pendingEndRef.current = false
|
||||
setChapterIndex(audioChapterIndex)
|
||||
}, [audioChapterIndex, chapterIndex, contentType])
|
||||
|
||||
// ── 换源(对应 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
|
||||
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
|
||||
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
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
|
||||
|
||||
// 听书:片头/片尾跳过秒数的落库改由播放引擎负责(它持有当前书 ID,离开本页也能写)。
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(-1)
|
||||
getReaderAudioEngine().prev()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
// 双页:一次退一屏;进度落在该屏的首张图上
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex > 0) setComicPage(comicSpreads[comicSpreadIndex - 1][0])
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
}
|
||||
if (comicPage > 0) setComicPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
@@ -350,14 +550,19 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page > 0) setPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
}, [contentType, settings.pageMode, comicPage, page, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(1)
|
||||
getReaderAudioEngine().next()
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
if (comicDoublePage && comicSpreads.length > 0) {
|
||||
if (comicSpreadIndex < comicSpreads.length - 1) setComicPage(comicSpreads[comicSpreadIndex + 1][0])
|
||||
else goChapter(1)
|
||||
return
|
||||
}
|
||||
const n = media?.images?.length ?? 0
|
||||
if (comicPage < n - 1) setComicPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
@@ -371,7 +576,22 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
|
||||
|
||||
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
|
||||
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
|
||||
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
|
||||
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
||||
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
||||
const handleZoneTap = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
|
||||
if (x < 0.3) goPrev()
|
||||
else if (x > 0.7) goNext()
|
||||
else setMenuOpen((v) => !v)
|
||||
},
|
||||
[goPrev, goNext],
|
||||
)
|
||||
|
||||
// ── 键盘(桌面端) ──
|
||||
useEffect(() => {
|
||||
@@ -393,12 +613,13 @@ export default function ReaderViewPage() {
|
||||
}, [goPrev, goNext, menuOpen, panel])
|
||||
|
||||
// ── 鼠标滚轮翻页(仅翻页模式) ──
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式的滚轮不在这里处理,
|
||||
// 由 useSmoothWheelScroll 接管做平滑惯性滚动。
|
||||
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
|
||||
useEffect(() => {
|
||||
const el = readerRef.current
|
||||
if (!el) return
|
||||
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
|
||||
// 滚动模式/听书面板/漫画滚动交给各自的滚动逻辑;菜单打开时不翻页
|
||||
if (settings.pageMode !== 'page' || menuOpen) return
|
||||
if (contentType !== 'text' && contentType !== 'image') return
|
||||
|
||||
@@ -533,29 +754,10 @@ export default function ReaderViewPage() {
|
||||
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||
{/* 正文视口 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
|
||||
{contentType === 'audio' ? (
|
||||
media && media.tracks && media.tracks.length > 0 ? (
|
||||
<ReaderAudioPanel
|
||||
src={media.tracks[0]}
|
||||
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||
cover={book?.cover_url ?? ''}
|
||||
theme={theme}
|
||||
initialPos={restorePos}
|
||||
openCredits={book?.open_credits ?? 0}
|
||||
closeCredits={book?.close_credits ?? 0}
|
||||
chapters={chapters}
|
||||
chapterIndex={chapterIndex}
|
||||
hasNext={hasNextAudioChapter}
|
||||
transcoding={media.transcoding ?? false}
|
||||
onProgress={throttledMediaSave}
|
||||
onPrevChapter={() => goChapter(-1)}
|
||||
onNextChapter={() => goChapter(1)}
|
||||
onSelectChapter={jumpToChapter}
|
||||
onEnded={() => goChapter(1)}
|
||||
onCreditsChange={saveAudioCredits}
|
||||
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||
/>
|
||||
<ReaderAudioPanel theme={theme} onToggleMenu={() => setMenuOpen((v) => !v)} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
|
||||
@@ -563,10 +765,12 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
) : contentType === 'image' ? (
|
||||
<ReaderComic
|
||||
images={media?.images ?? []}
|
||||
images={comicImages}
|
||||
theme={theme}
|
||||
mode={settings.pageMode}
|
||||
page={comicPage}
|
||||
imageFit={comicScrollFit}
|
||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
@@ -604,6 +808,7 @@ export default function ReaderViewPage() {
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (chapterIndex === null) return
|
||||
@@ -616,7 +821,7 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
|
||||
className={`h-full overflow-y-auto overscroll-contain px-4 ${MENU_SHIFT}`}
|
||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||
>
|
||||
<div className="py-4">
|
||||
@@ -656,7 +861,10 @@ export default function ReaderViewPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
|
||||
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
|
||||
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
|
||||
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
|
||||
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
|
||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||
<button
|
||||
@@ -739,11 +947,13 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式) */}
|
||||
<div className="grid grid-cols-4 pt-1" style={{ color: theme.text }}>
|
||||
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。
|
||||
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
|
||||
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
|
||||
<div className="flex pt-1" style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
{
|
||||
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||
label: settings.night ? '日间' : '夜间',
|
||||
@@ -754,12 +964,34 @@ export default function ReaderViewPage() {
|
||||
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||
},
|
||||
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
|
||||
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
|
||||
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
? [
|
||||
{
|
||||
icon: <Columns2 size={18} />,
|
||||
label: settings.comicDoublePage ? '单页' : '双页',
|
||||
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canSwitchSource
|
||||
? [
|
||||
{
|
||||
icon: <ArrowLeftRight size={18} />,
|
||||
label: '换源',
|
||||
action: () => {
|
||||
void openSourcePicker()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]).map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs">{item.label}</span>
|
||||
@@ -767,7 +999,7 @@ export default function ReaderViewPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
|
||||
{panel === 'style' && (
|
||||
<div
|
||||
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
|
||||
@@ -821,6 +1053,30 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
|
||||
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
|
||||
{showComicImageFit && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="opacity-70">图片尺寸</span>
|
||||
{COMIC_IMAGE_FITS.map((item) => {
|
||||
const active = settings.comicImageFit === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => settings.setComicImageFit(item.id)}
|
||||
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
|
||||
style={{
|
||||
borderColor: active ? theme.accent : theme.text + '44',
|
||||
color: active ? theme.accent : theme.text,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -844,15 +1100,39 @@ export default function ReaderViewPage() {
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<p className="flex-1 truncate text-sm font-bold">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-bold">
|
||||
{book?.name ?? '目录'}
|
||||
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
||||
</p>
|
||||
{tocGroups.length > 1 && (
|
||||
<select
|
||||
value={tocGroupIndex}
|
||||
onChange={(e) => jumpToTocGroup(Number(e.target.value))}
|
||||
aria-label="按区间快速定位章节"
|
||||
title="按区间快速定位章节"
|
||||
className="shrink-0 rounded-lg border bg-transparent px-2 py-1 text-2xs outline-none"
|
||||
style={{ borderColor: theme.text + '33', color: theme.text }}
|
||||
>
|
||||
{tocGroups.map((group) => (
|
||||
<option
|
||||
key={group.index}
|
||||
value={group.index}
|
||||
style={{ color: '#111827', backgroundColor: '#ffffff' }}
|
||||
>
|
||||
{group.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<Virtuoso
|
||||
ref={tocRef}
|
||||
data={chapters}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
rangeChanged={(range) =>
|
||||
setTocGroupIndex(chapterGroupIndexOf(range.startIndex, chapters.length))
|
||||
}
|
||||
itemContent={(index, ch) => {
|
||||
const isCurrent = index === chapterIndex
|
||||
return (
|
||||
@@ -879,6 +1159,19 @@ export default function ReaderViewPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{switchOpen && (
|
||||
<SourcePickerDialog
|
||||
title={book?.name ?? '这本书'}
|
||||
origins={switchCandidates}
|
||||
current={{ originURL: book?.origin, bookURL: book?.book_url }}
|
||||
loading={switchLoading}
|
||||
emptyHint="按书名重搜后没有找到其它书源"
|
||||
onPick={(origin) => {
|
||||
void applyOrigin(origin)
|
||||
}}
|
||||
onClose={() => setSwitchOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Minus, Search, X } from 'lucide-react'
|
||||
|
||||
import type { ReaderSource } from '../../api/reader'
|
||||
|
||||
/**
|
||||
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
|
||||
*
|
||||
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
|
||||
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
|
||||
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
|
||||
*
|
||||
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
|
||||
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
|
||||
*/
|
||||
export function SearchScopeDialog({
|
||||
sources,
|
||||
selectedIds,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
sources: ReaderSource[]
|
||||
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
|
||||
selectedIds: string[]
|
||||
onConfirm: (ids: string[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
|
||||
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
|
||||
|
||||
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const valid = selectedIds.filter((id) => enabledIds.includes(id))
|
||||
return new Set(valid.length > 0 ? valid : enabledIds)
|
||||
})
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const keyword = filter.trim().toLowerCase()
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
sources.filter(
|
||||
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
|
||||
),
|
||||
[sources, keyword],
|
||||
)
|
||||
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ReaderSource[]>()
|
||||
for (const s of visible) {
|
||||
const key = (s.group ?? '').trim() || '未分组'
|
||||
const list = map.get(key)
|
||||
if (list) list.push(s)
|
||||
else map.set(key, [s])
|
||||
}
|
||||
return [...map.entries()].map(([name, items]) => ({ name, items }))
|
||||
}, [visible])
|
||||
|
||||
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
|
||||
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleGroup = (ids: string[]) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allIn = ids.every((id) => next.has(id))
|
||||
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
|
||||
return next
|
||||
})
|
||||
|
||||
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
|
||||
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
|
||||
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
|
||||
const clearVisible = () =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
visibleEnabledIds.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
|
||||
const confirm = () => {
|
||||
const picked = enabledIds.filter((id) => selected.has(id))
|
||||
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
|
||||
onConfirm(picked.length === enabledIds.length ? [] : picked)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
|
||||
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="过滤书源名 / 分组"
|
||||
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
|
||||
全选
|
||||
</button>
|
||||
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||
{sources.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => {
|
||||
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
|
||||
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
|
||||
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={groupEnabledIds.length === 0}
|
||||
onClick={() => toggleGroup(groupEnabledIds)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
|
||||
>
|
||||
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
|
||||
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
|
||||
</button>
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
disabled={!src.enabled}
|
||||
onClick={() => toggle(src.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
|
||||
{!src.enabled && (
|
||||
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
|
||||
<span className="flex-1 text-2xs text-[var(--app-muted)]">
|
||||
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="btn-outline text-xs">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
|
||||
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
|
||||
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
|
||||
} ${disabled ? 'opacity-40' : ''}`}
|
||||
>
|
||||
{checked && <Check size={11} strokeWidth={3} />}
|
||||
{!checked && partial && <Minus size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BookOpen, Check, Loader2, X } from 'lucide-react'
|
||||
|
||||
import type { ReaderSearchOrigin } from '../../api/reader'
|
||||
|
||||
/**
|
||||
* SourcePickerDialog 换源弹窗:列出同一本书可用的书源,选中即切换。
|
||||
*
|
||||
* 书源对同一本书的封面 / 最新章节等覆盖能力不同,所以列表里带上各源的最新章节,
|
||||
* 方便挑一个更全的源;当前正在使用的源会标注「当前」且不可重复选择。
|
||||
*/
|
||||
export function SourcePickerDialog({
|
||||
title,
|
||||
origins,
|
||||
current,
|
||||
loading = false,
|
||||
emptyHint = '没有找到其它书源',
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
origins: ReaderSearchOrigin[]
|
||||
/** 当前正在使用的源:优先按书本地址匹配(同源可能承载多本书),其次按书源地址 */
|
||||
current?: { originURL?: string; bookURL?: string }
|
||||
loading?: boolean
|
||||
emptyHint?: string
|
||||
onPick: (origin: ReaderSearchOrigin) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">换源 · {title}</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
{loading ? '正在搜索其它书源…' : `共 ${origins.length} 个可用书源 · 阅读进度会保留`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
</div>
|
||||
) : origins.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs text-[var(--app-muted)]">{emptyHint}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{origins.map((origin) => {
|
||||
const isCurrent =
|
||||
(!!current?.bookURL && origin.book_url === current.bookURL) ||
|
||||
(!current?.bookURL && !!current?.originURL && origin.origin === current.originURL)
|
||||
return (
|
||||
<button
|
||||
key={`${origin.source_id}|${origin.origin}|${origin.book_url}`}
|
||||
type="button"
|
||||
disabled={isCurrent}
|
||||
onClick={() => onPick(origin)}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-60"
|
||||
>
|
||||
<BookOpen size={15} className="shrink-0 text-brand-500" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{origin.origin_name || origin.origin}
|
||||
</span>
|
||||
<span className="block truncate text-2xs text-[var(--app-muted)]">
|
||||
{origin.latest_chapter
|
||||
? `最新:${origin.latest_chapter}`
|
||||
: `书源:${origin.origin || origin.source_id}`}
|
||||
</span>
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-2xs font-bold text-brand-600">
|
||||
<Check size={12} /> 当前
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-2xs text-brand-600">切换</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`bookshelfModel: ${name}`)
|
||||
}
|
||||
|
||||
function book(overrides: Partial<ReaderBook>): ReaderBook {
|
||||
return {
|
||||
id: overrides.id ?? 'x',
|
||||
origin: '',
|
||||
origin_name: '',
|
||||
book_url: '',
|
||||
toc_url: '',
|
||||
name: '书',
|
||||
author: '',
|
||||
kind: '',
|
||||
cover_url: '',
|
||||
intro: '',
|
||||
type: 0,
|
||||
latest_chapter_title: '',
|
||||
latest_chapter_time: 0,
|
||||
total_chapter_num: 0,
|
||||
dur_chapter_index: 0,
|
||||
dur_chapter_pos: 0,
|
||||
dur_chapter_title: '',
|
||||
dur_chapter_time: 0,
|
||||
order: 0,
|
||||
is_local: false,
|
||||
local_external: false,
|
||||
open_credits: 0,
|
||||
close_credits: 0,
|
||||
...overrides,
|
||||
} as ReaderBook
|
||||
}
|
||||
|
||||
// ── 未读章数 ──
|
||||
check('no cached toc → null', unreadChapters(book({ total_chapter_num: 0, dur_chapter_index: 3 })) === null)
|
||||
check('unread book counts all chapters', unreadChapters(book({ total_chapter_num: 10 })) === 10)
|
||||
check(
|
||||
'reading chapter 3 of 10 leaves 7',
|
||||
unreadChapters(book({ total_chapter_num: 10, dur_chapter_index: 2, dur_chapter_time: 1 })) === 7,
|
||||
)
|
||||
check(
|
||||
'progress past the end clamps to 0',
|
||||
unreadChapters(book({ total_chapter_num: 5, dur_chapter_index: 99, dur_chapter_time: 1 })) === 0,
|
||||
)
|
||||
// dur_chapter_time 为 0 表示还没开始读,即使 index > 0 也不该算作已读
|
||||
check('zero dur_chapter_time means unread', unreadChapters(book({ total_chapter_num: 4, dur_chapter_index: 2 })) === 4)
|
||||
|
||||
// ── 排序 ──
|
||||
const a = book({ id: 'a', name: 'β', dur_chapter_time: 100, latest_chapter_time: 500, order: 2 })
|
||||
const b = book({ id: 'b', name: 'α', dur_chapter_time: 300, latest_chapter_time: 100, order: 1 })
|
||||
const c = book({ id: 'c', name: 'γ', dur_chapter_time: 0, latest_chapter_time: 900, order: 3 })
|
||||
|
||||
check('recent sorts by read time desc', sortShelfBooks([a, b, c], 'recent').map((x) => x.id).join('') === 'bac')
|
||||
check('update sorts by latest chapter time desc', sortShelfBooks([a, b, c], 'update').map((x) => x.id).join('') === 'cab')
|
||||
check('manual sorts by order asc', sortShelfBooks([a, b, c], 'manual').map((x) => x.id).join('') === 'bac')
|
||||
check('name sorts by title', sortShelfBooks([a, b, c], 'name').map((x) => x.id).join('') === 'bac')
|
||||
check(
|
||||
'mixed uses the newer of read/update time',
|
||||
sortShelfBooks([a, b, c], 'mixed').map((x) => x.id).join('') === 'cab',
|
||||
)
|
||||
check('sort does not mutate the input', [a, b, c].map((x) => x.id).join('') === 'abc')
|
||||
|
||||
// 作者相同按书名兜底
|
||||
const d1 = book({ id: 'd1', name: 'B', author: '同一作者' })
|
||||
const d2 = book({ id: 'd2', name: 'A', author: '同一作者' })
|
||||
check('author then title', sortShelfBooks([d1, d2], 'author').map((x) => x.id).join('') === 'd2d1')
|
||||
|
||||
// ── 时间格式化 ──
|
||||
check('zero time → empty', formatRelativeTime(0) === '')
|
||||
check('just now', formatRelativeTime(Date.now() - 5_000) === '刚刚')
|
||||
check('minutes ago', formatRelativeTime(Date.now() - 5 * 60_000) === '5 分钟前')
|
||||
check('hours ago', formatRelativeTime(Date.now() - 3 * 3_600_000) === '3 小时前')
|
||||
check('days ago', formatRelativeTime(Date.now() - 2 * 86_400_000) === '2 天前')
|
||||
|
||||
console.log('bookshelfModel.test.ts ok')
|
||||
@@ -0,0 +1,123 @@
|
||||
// 书架展示与排序的纯逻辑(不依赖 React,便于单测)。
|
||||
//
|
||||
// 语义对齐 legado 书架:未读章数取自 getUnreadChapterNum,「最近更新」取自
|
||||
// Book.latestChapterTime,排序项与 AppConfig.getBookSortByGroupId 的六个取值一一对应。
|
||||
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import type { ReaderShelfLayout, ReaderShelfSort } from '../../stores/readerSettings'
|
||||
|
||||
/** 未读章数:total_chapter_num 为 0 表示目录尚未缓存,返回 null(前端不显示徽标)。 */
|
||||
export function unreadChapters(book: ReaderBook): number | null {
|
||||
if (book.total_chapter_num <= 0) return null
|
||||
// dur_chapter_time 为 0 表示还没开始读,否则读到 dur_chapter_index 为止。
|
||||
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||
}
|
||||
|
||||
/** 书籍的「最近更新」时间(毫秒),未检测到更新时为 0。 */
|
||||
export function shelfUpdateTime(book: ReaderBook): number {
|
||||
return Number.isFinite(book.latest_chapter_time) ? book.latest_chapter_time : 0
|
||||
}
|
||||
|
||||
function byTitle(a: ReaderBook, b: ReaderBook): number {
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN')
|
||||
}
|
||||
|
||||
function byAuthor(a: ReaderBook, b: ReaderBook): number {
|
||||
return (a.author || '').localeCompare(b.author || '', 'zh-Hans-CN') || byTitle(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按所选方式排序书架,不修改入参。
|
||||
*
|
||||
* - recent:最近阅读(dur_chapter_time 倒序,没读过的按书名排后面)——legado 默认
|
||||
* - update:最近更新(latest_chapter_time 倒序)
|
||||
* - name / author:书名 / 作者
|
||||
* - mixed:综合(阅读时间与更新时间取较新者)
|
||||
* - manual:手动顺序(服务端 order 字段)
|
||||
*/
|
||||
export function sortShelfBooks(books: ReaderBook[], sort: ReaderShelfSort): ReaderBook[] {
|
||||
const list = [...books]
|
||||
switch (sort) {
|
||||
case 'update':
|
||||
return list.sort(
|
||||
(a, b) => shelfUpdateTime(b) - shelfUpdateTime(a) || b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b),
|
||||
)
|
||||
case 'name':
|
||||
return list.sort(byTitle)
|
||||
case 'author':
|
||||
return list.sort(byAuthor)
|
||||
case 'mixed':
|
||||
return list.sort(
|
||||
(a, b) =>
|
||||
Math.max(shelfUpdateTime(b), b.dur_chapter_time) - Math.max(shelfUpdateTime(a), a.dur_chapter_time) ||
|
||||
byTitle(a, b),
|
||||
)
|
||||
case 'manual':
|
||||
return list.sort((a, b) => a.order - b.order || byTitle(a, b))
|
||||
case 'recent':
|
||||
default:
|
||||
return list.sort((a, b) => b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b))
|
||||
}
|
||||
}
|
||||
|
||||
/** 绝对时间:当天只显示时分,同年显示月日,跨年带年份。 */
|
||||
export function formatBookTime(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const d = new Date(ms)
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 相对时间:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 30 天退回日期。 */
|
||||
export function formatRelativeTime(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const diff = Date.now() - ms
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
|
||||
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
||||
return formatBookTime(ms)
|
||||
}
|
||||
|
||||
/** 「读到」文案;未开始时返回空串。 */
|
||||
export function readProgressText(book: ReaderBook): string {
|
||||
if (book.dur_chapter_time <= 0 || !book.dur_chapter_title) return ''
|
||||
return book.dur_chapter_title
|
||||
}
|
||||
|
||||
export const SHELF_LAYOUT_OPTIONS: { value: ReaderShelfLayout; label: string; hint: string }[] = [
|
||||
{ value: 'grid', label: '网格', hint: '封面宫格,可调列数' },
|
||||
{ value: 'list', label: '列表', hint: '封面 + 书名 + 读到 / 最新章节' },
|
||||
{ value: 'compact', label: '紧凑列表', hint: '小封面单行,一屏放更多书' },
|
||||
]
|
||||
|
||||
export const SHELF_SORT_OPTIONS: { value: ReaderShelfSort; label: string }[] = [
|
||||
{ value: 'recent', label: '最近阅读' },
|
||||
{ value: 'update', label: '最近更新' },
|
||||
{ value: 'mixed', label: '综合' },
|
||||
{ value: 'name', label: '按书名' },
|
||||
{ value: 'author', label: '按作者' },
|
||||
{ value: 'manual', label: '手动顺序' },
|
||||
]
|
||||
|
||||
/** 网格列数选项的展示文案。 */
|
||||
export function gridColumnsLabel(columns: number): string {
|
||||
return columns === 0 ? '自适应' : `${columns} 列`
|
||||
}
|
||||
|
||||
/** 书籍详情页地址(/reader/book),与搜索结果的跳转参数保持一致。 */
|
||||
export function bookDetailPath(book: ReaderBook): string {
|
||||
const params = new URLSearchParams({
|
||||
source_url: book.origin,
|
||||
book_url: book.book_url,
|
||||
name: book.name,
|
||||
author: book.author || '',
|
||||
cover_url: book.cover_url || '',
|
||||
origin_name: book.origin_name || '',
|
||||
})
|
||||
return `/reader/book?${params.toString()}`
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// 听书播放引擎:模块级单例,持有真正的 <audio>(不进 React 树,也不进任何路由)。
|
||||
//
|
||||
// 为什么要脱离组件:音频面板原先挂在 ReaderViewPage 里,路由一变组件卸载,清理
|
||||
// 逻辑就 removeAttribute('src') + load(),播放当场断掉;手机息屏、系统回收标签页
|
||||
// 时更没有任何东西证明「这个页面正在播媒体」。把播放器做成常驻单例后:
|
||||
// - 路由切换不影响播放;
|
||||
// - Media Session 让锁屏/通知栏/耳机按键接管控制(Android Chrome 会据此判定
|
||||
// 页面处于活跃媒体会话,后台存活率明显更高);
|
||||
// - 定时关闭改由 timeupdate(后台播放时仍触发)+ 低频 ticker 驱动,不再依赖
|
||||
// 被节流的 setInterval 来「到点暂停」。
|
||||
//
|
||||
// 进度上报也在这里:引擎自己知道当前书/章,离开页面、暂停、切章时都能补报一次,
|
||||
// 不依赖调用方在卸载时回调。
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { readerAPI, type ReaderChapter } from '../../api/reader'
|
||||
import { useReaderAudioStore, resetReaderAudio } from '../../stores/readerAudio'
|
||||
import { useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
|
||||
/** 播放进度上报节流(毫秒):退出最多丢 5 秒。 */
|
||||
const PROGRESS_SAVE_INTERVAL_MS = 5_000
|
||||
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
|
||||
const SEEK_STEP = 15
|
||||
|
||||
export interface LoadChapterInput {
|
||||
bookId: string
|
||||
bookName: string
|
||||
cover: string
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number
|
||||
/** 当前章的音轨地址(已签名)。 */
|
||||
track: string
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
/** 恢复进度(秒);0 表示从头。 */
|
||||
initialPos: number
|
||||
}
|
||||
|
||||
class ReaderAudioEngine {
|
||||
private audio: HTMLAudioElement
|
||||
/** hls.js 实例(只用到 destroy)。 */
|
||||
private hls: { destroy: () => void } | null = null
|
||||
/** 元数据就绪后是否已经套用过进度/片头跳转。 */
|
||||
private restored = false
|
||||
/** 本章是否已经触发过片尾跳过,避免 timeupdate 反复触发。 */
|
||||
private skippedEnd = false
|
||||
/** 最近一次播放位置(秒):节流窗口外补报进度时要用。 */
|
||||
private lastTime = 0
|
||||
private lastSavedAt = 0
|
||||
/** 上一次落库的进度标识(书|章|秒),相同则不重复请求。 */
|
||||
private savedKey = ''
|
||||
/** 定时关闭截止时间戳(毫秒),0 表示未开启。 */
|
||||
private timerDeadline = 0
|
||||
/** 暂停开始的毫秒时间戳,0 表示当前在播(定时倒计时只在播放中走)。 */
|
||||
private pausedAt = 0
|
||||
private timerTicker: number | null = null
|
||||
/** 一次性的初始进度,等 metadata 就绪后消费。 */
|
||||
private pendingInitialPos = 0
|
||||
/** 是否已经套用过「上次的定时设置」,只在首次加载时套一次。 */
|
||||
private timerBooted = false
|
||||
/** 正在拆卸音源(避免拆卸触发的 pause 事件被当成用户暂停)。 */
|
||||
private detaching = false
|
||||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||||
private loadToken = 0
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'metadata'
|
||||
this.audio = audio
|
||||
this.bindAudio()
|
||||
this.bindLifecycle()
|
||||
this.bindSettings()
|
||||
this.bindMediaSession()
|
||||
}
|
||||
|
||||
// ─── 对外 API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载并播放一章。同一本书同一章重复调用(阅读页 effect 重跑、目录同步回写)
|
||||
* 会被忽略,避免把正在播的进度重置掉。
|
||||
*/
|
||||
loadChapter(input: LoadChapterInput): void {
|
||||
const s = useReaderAudioStore.getState()
|
||||
if (
|
||||
s.bookId === input.bookId &&
|
||||
s.chapterIndex === input.chapterIndex &&
|
||||
s.track === input.track &&
|
||||
(s.status === 'loading' || s.status === 'playing' || s.status === 'paused')
|
||||
) {
|
||||
return
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
|
||||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||||
useReaderAudioStore.setState({
|
||||
bookId: input.bookId,
|
||||
bookName: input.bookName,
|
||||
cover: input.cover,
|
||||
chapters: input.chapters,
|
||||
chapterIndex: input.chapterIndex,
|
||||
chapterTitle,
|
||||
track: input.track,
|
||||
transcoding: input.transcoding,
|
||||
openCredits: input.openCredits,
|
||||
closeCredits: input.closeCredits,
|
||||
hasNext: this.computeHasNext(input.chapters, input.chapterIndex),
|
||||
status: 'loading',
|
||||
error: '',
|
||||
cur: 0,
|
||||
dur: 0,
|
||||
})
|
||||
|
||||
this.restored = false
|
||||
this.skippedEnd = false
|
||||
this.lastTime = 0
|
||||
this.lastSavedAt = 0
|
||||
this.savedKey = ''
|
||||
this.pendingInitialPos = Math.max(0, input.initialPos)
|
||||
|
||||
// 进场套用上次的定时设置(对应 legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
if (!this.timerBooted) {
|
||||
this.timerBooted = true
|
||||
const preset = useReaderSettingsStore.getState().audioTimerMinutes
|
||||
if (preset > 0) this.applyTimer(preset)
|
||||
}
|
||||
|
||||
void this.attachSource(input.track)
|
||||
}
|
||||
|
||||
play(): void {
|
||||
void this.audio.play().catch(() => {
|
||||
// 自动播放被浏览器拦下(典型是 iOS):退回暂停态,让用户点按钮手动触发,
|
||||
// 而不是一直停在「加载中」。
|
||||
if (useReaderAudioStore.getState().status === 'loading') {
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.audio.pause()
|
||||
// 还没触发 play 事件时(status 仍是 loading),下面的 pause 事件处理会跳过状态
|
||||
// 判定;这里补一次,保证按钮不会一直转圈。
|
||||
if (useReaderAudioStore.getState().status === 'loading') {
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.audio.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
seekBy(delta: number): void {
|
||||
const total = Number.isFinite(this.audio.duration)
|
||||
? this.audio.duration
|
||||
: this.audio.currentTime + Math.abs(delta)
|
||||
this.seekTo(Math.min(Math.max(0, this.audio.currentTime + delta), Math.max(0, total - 0.2)))
|
||||
}
|
||||
|
||||
seekTo(seconds: number): void {
|
||||
if (!Number.isFinite(seconds)) return
|
||||
this.audio.currentTime = Math.max(0, seconds)
|
||||
this.lastTime = this.audio.currentTime
|
||||
useReaderAudioStore.setState({ cur: this.audio.currentTime })
|
||||
this.updateSessionPosition()
|
||||
}
|
||||
|
||||
/** 倍速改动写回 settings store,订阅回调负责应用到 <audio>。 */
|
||||
setSpeed(v: number): void {
|
||||
useReaderSettingsStore.getState().setAudioSpeed(v)
|
||||
}
|
||||
|
||||
/** 片头/片尾跳过秒数:立刻生效并按书持久化。 */
|
||||
setCredits(open: number, close: number): void {
|
||||
useReaderAudioStore.setState({ openCredits: open, closeCredits: close })
|
||||
const { bookId } = useReaderAudioStore.getState()
|
||||
if (!bookId) return
|
||||
readerAPI.saveAudioConfig(bookId, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** 定时关闭:0 表示关闭定时。 */
|
||||
applyTimer(minutes: number): void {
|
||||
useReaderSettingsStore.getState().setAudioTimerMinutes(minutes)
|
||||
useReaderAudioStore.setState({ timerPreset: minutes })
|
||||
if (minutes <= 0) {
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
this.stopTimerTicker()
|
||||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||||
return
|
||||
}
|
||||
this.timerDeadline = Date.now() + minutes * 60_000
|
||||
// 暂停期间不倒计时:记下暂停起点,恢复播放时顺延截止时间
|
||||
this.pausedAt = this.audio.paused ? Date.now() : 0
|
||||
useReaderAudioStore.setState({ timerLeft: minutes * 60, timerActive: true })
|
||||
this.startTimerTicker()
|
||||
}
|
||||
|
||||
selectChapter(index: number): void {
|
||||
void this.goToChapter(index, 0)
|
||||
}
|
||||
|
||||
next(): void {
|
||||
const i = this.neighborChapterIndex(1)
|
||||
if (i === null) return
|
||||
void this.goToChapter(i, 0)
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
const i = this.neighborChapterIndex(-1)
|
||||
if (i === null) return
|
||||
void this.goToChapter(i, 0)
|
||||
}
|
||||
|
||||
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.detachSource()
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||||
this.timerBooted = false
|
||||
this.stopTimerTicker()
|
||||
this.setSessionMetadata(null)
|
||||
this.setSessionPlaybackState('none')
|
||||
resetReaderAudio()
|
||||
}
|
||||
|
||||
/** 立即补报一次进度(不吃节流窗口)。 */
|
||||
commitProgress(): void {
|
||||
this.lastSavedAt = 0
|
||||
this.persistProgress()
|
||||
}
|
||||
|
||||
// ─── 音源装载 ─────────────────────────────────────────────────────────
|
||||
|
||||
private async attachSource(src: string): Promise<void> {
|
||||
const token = ++this.loadToken
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
if (!src) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||||
if (token !== this.loadToken) return
|
||||
const Hls = mod.default
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
if (data.fatal) this.fail('音频加载失败,请检查网络或源文件是否还在')
|
||||
})
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(this.audio)
|
||||
this.hls = inst
|
||||
return
|
||||
}
|
||||
// Safari 原生 HLS
|
||||
this.audio.src = src
|
||||
return
|
||||
}
|
||||
this.audio.src = src
|
||||
}
|
||||
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
private async goToChapter(index: number, initialPos: number): Promise<void> {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(bookId, index)
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.loadChapter({
|
||||
bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters,
|
||||
chapterIndex: index,
|
||||
track,
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos,
|
||||
})
|
||||
} catch (e) {
|
||||
this.fail((e as Error).message || '章节加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
useReaderAudioStore.setState({ status: 'error', error: message })
|
||||
this.setSessionPlaybackState('paused')
|
||||
}
|
||||
|
||||
// ─── <audio> 事件 ─────────────────────────────────────────────────────
|
||||
|
||||
private bindAudio(): void {
|
||||
const audio = this.audio
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
useReaderAudioStore.setState({ status: 'playing' })
|
||||
this.setSessionPlaybackState('playing')
|
||||
// 恢复播放:把暂停时长顺延到定时截止时间
|
||||
if (this.pausedAt > 0) {
|
||||
this.timerDeadline += Date.now() - this.pausedAt
|
||||
this.pausedAt = 0
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
if (this.detaching) return
|
||||
// 只认「播放中 -> 暂停」这一种。换源/停止时 load() 也会抛出 pause 事件,
|
||||
// 那些时刻 status 是 loading/idle,直接忽略;否则会把刚进入的加载态改成暂停,
|
||||
// 并给新章节写一条位置为 0 的进度。
|
||||
if (useReaderAudioStore.getState().status !== 'playing') return
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
this.commitProgress()
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||||
this.commitProgress()
|
||||
if (useReaderAudioStore.getState().hasNext) {
|
||||
this.next()
|
||||
return
|
||||
}
|
||||
// 最后一章播完:ended 不会触发 pause,倒计时/暂停态要在这里自己落定
|
||||
if (this.pausedAt === 0) this.pausedAt = Date.now()
|
||||
useReaderAudioStore.setState({ status: 'paused' })
|
||||
this.setSessionPlaybackState('paused')
|
||||
})
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
useReaderAudioStore.setState({ dur: total })
|
||||
if (!this.restored) {
|
||||
const { openCredits } = useReaderAudioStore.getState()
|
||||
if (this.pendingInitialPos > 0 && (total === 0 || this.pendingInitialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = this.pendingInitialPos
|
||||
useReaderAudioStore.setState({ cur: this.pendingInitialPos })
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
useReaderAudioStore.setState({ cur: openCredits })
|
||||
}
|
||||
this.pendingInitialPos = 0
|
||||
}
|
||||
this.restored = true
|
||||
audio.playbackRate = useReaderAudioStore.getState().speed
|
||||
this.play()
|
||||
})
|
||||
|
||||
audio.addEventListener('durationchange', () => {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
useReaderAudioStore.setState({ dur: total })
|
||||
this.updateSessionPosition()
|
||||
})
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const t = audio.currentTime
|
||||
this.lastTime = t
|
||||
useReaderAudioStore.setState({ cur: t })
|
||||
this.updateSessionPosition()
|
||||
this.reportProgress()
|
||||
this.checkCreditsEnd()
|
||||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||||
this.checkTimer()
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
const code = audio.error?.code
|
||||
this.fail(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 页面生命周期:只负责补报进度,绝不暂停播放 ───────────────────────
|
||||
|
||||
private bindLifecycle(): void {
|
||||
const flush = () => this.commitProgress()
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
window.addEventListener('pagehide', flush)
|
||||
}
|
||||
|
||||
private bindSettings(): void {
|
||||
const apply = () => {
|
||||
const { audioSpeed } = useReaderSettingsStore.getState()
|
||||
this.audio.playbackRate = audioSpeed
|
||||
useReaderAudioStore.setState({ speed: audioSpeed })
|
||||
}
|
||||
apply()
|
||||
// 引擎是常驻单例,订阅不需要解除
|
||||
useReaderSettingsStore.subscribe(apply)
|
||||
}
|
||||
|
||||
// ─── 进度 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private reportProgress(): void {
|
||||
const now = Date.now()
|
||||
if (now - this.lastSavedAt < PROGRESS_SAVE_INTERVAL_MS) return
|
||||
this.lastSavedAt = now
|
||||
this.persistProgress()
|
||||
}
|
||||
|
||||
private persistProgress(): void {
|
||||
const { bookId, chapterIndex, chapterTitle } = useReaderAudioStore.getState()
|
||||
if (!bookId || this.lastTime <= 0) return
|
||||
const pos = Math.max(0, Math.floor(this.lastTime))
|
||||
const key = `${bookId}|${chapterIndex}|${pos}`
|
||||
if (key === this.savedKey) return
|
||||
this.savedKey = key
|
||||
readerAPI.saveProgress(bookId, { chapter_index: chapterIndex, pos, chapter_title: chapterTitle }).catch(() => undefined)
|
||||
}
|
||||
|
||||
// ─── 片头片尾 ─────────────────────────────────────────────────────────
|
||||
|
||||
private checkCreditsEnd(): void {
|
||||
const { closeCredits, hasNext } = useReaderAudioStore.getState()
|
||||
const audio = this.audio
|
||||
if (closeCredits <= 0 || this.skippedEnd) return
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= closeCredits + 1) return
|
||||
if (audio.currentTime < audio.duration - closeCredits) return
|
||||
this.skippedEnd = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
this.next()
|
||||
}
|
||||
|
||||
// ─── 定时关闭 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** 剩余秒数:暂停期间以暂停起点为准,倒计时不会在暂停时溜走。 */
|
||||
private timerLeftSeconds(): number {
|
||||
if (this.timerDeadline === 0) return 0
|
||||
const now = this.pausedAt > 0 ? this.pausedAt : Date.now()
|
||||
return Math.max(0, Math.round((this.timerDeadline - now) / 1000))
|
||||
}
|
||||
|
||||
private checkTimer(): void {
|
||||
const s = useReaderAudioStore.getState()
|
||||
if (!s.timerActive || this.timerDeadline === 0) return
|
||||
const left = this.timerLeftSeconds()
|
||||
if (left !== s.timerLeft) useReaderAudioStore.setState({ timerLeft: left })
|
||||
// 暂停中不判到期:恢复播放时截止时间已经顺延过
|
||||
if (left <= 0 && this.pausedAt === 0) {
|
||||
this.timerDeadline = 0
|
||||
this.stopTimerTicker()
|
||||
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
|
||||
this.audio.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}
|
||||
|
||||
private startTimerTicker(): void {
|
||||
this.stopTimerTicker()
|
||||
this.timerTicker = window.setInterval(() => this.checkTimer(), 1000)
|
||||
}
|
||||
|
||||
private stopTimerTicker(): void {
|
||||
if (this.timerTicker !== null) {
|
||||
window.clearInterval(this.timerTicker)
|
||||
this.timerTicker = null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Media Session(锁屏 / 通知栏 / 耳机按键) ────────────────────────
|
||||
|
||||
private session(): MediaSession | null {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return null
|
||||
return navigator.mediaSession
|
||||
}
|
||||
|
||||
private setSessionMetadata(track: string | null): void {
|
||||
const session = this.session()
|
||||
if (!session || typeof MediaMetadata === 'undefined') return
|
||||
if (track === null) {
|
||||
session.metadata = null
|
||||
return
|
||||
}
|
||||
const { bookName, chapterTitle, cover } = useReaderAudioStore.getState()
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
const artwork: MediaImage[] = coverSrc
|
||||
? [{ src: new URL(coverSrc, window.location.origin).href, sizes: '512x512' }]
|
||||
: []
|
||||
try {
|
||||
session.metadata = new MediaMetadata({
|
||||
title: chapterTitle || bookName || 'MeBox 听书',
|
||||
artist: bookName || 'MeBox',
|
||||
album: 'MeBox 听书',
|
||||
artwork,
|
||||
})
|
||||
} catch {
|
||||
// 个别浏览器对 artwork 类型挑剔,失败不影响播放
|
||||
}
|
||||
}
|
||||
|
||||
private setSessionPlaybackState(state: MediaSessionPlaybackState): void {
|
||||
const session = this.session()
|
||||
if (!session) return
|
||||
try {
|
||||
session.playbackState = state
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
private updateSessionPosition(): void {
|
||||
const session = this.session()
|
||||
if (!session || typeof session.setPositionState !== 'function') return
|
||||
const { dur } = useReaderAudioStore.getState()
|
||||
const position = this.audio.currentTime
|
||||
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(position)) return
|
||||
const rate = this.audio.playbackRate
|
||||
try {
|
||||
session.setPositionState({
|
||||
duration: dur,
|
||||
position: Math.min(Math.max(0, position), dur),
|
||||
playbackRate: Number.isFinite(rate) && rate > 0 ? rate : 1,
|
||||
})
|
||||
} catch {
|
||||
// position 越界等情况下浏览器会抛错,忽略
|
||||
}
|
||||
}
|
||||
|
||||
private bindMediaSession(): void {
|
||||
const session = this.session()
|
||||
if (!session) return
|
||||
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
|
||||
['play', () => this.play()],
|
||||
['pause', () => this.pause()],
|
||||
['stop', () => this.stop()],
|
||||
['seekbackward', () => this.seekBy(-SEEK_STEP)],
|
||||
['seekforward', () => this.seekBy(SEEK_STEP)],
|
||||
['previoustrack', () => this.prev()],
|
||||
['nexttrack', () => this.next()],
|
||||
[
|
||||
'seekto',
|
||||
(details) => {
|
||||
if (typeof details.seekTime === 'number') this.seekTo(details.seekTime)
|
||||
},
|
||||
],
|
||||
]
|
||||
for (const [action, handler] of handlers) {
|
||||
try {
|
||||
session.setActionHandler(action, handler)
|
||||
} catch {
|
||||
// 该动作在当前浏览器不支持时会抛错,逐个忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private neighborChapterIndex(dir: 1 | -1): number | null {
|
||||
const { chapters, chapterIndex } = useReaderAudioStore.getState()
|
||||
for (let i = chapterIndex + dir; i >= 0 && i < chapters.length; i += dir) {
|
||||
if (!chapters[i].is_volume) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private computeHasNext(chapters: ReaderChapter[], index: number): boolean {
|
||||
for (let i = index + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let engine: ReaderAudioEngine | null = null
|
||||
|
||||
/**
|
||||
* 取全局听书引擎(首次调用时才构造,避免没听过书的应用也创建一个 <audio>)。
|
||||
*/
|
||||
export function getReaderAudioEngine(): ReaderAudioEngine {
|
||||
if (!engine) engine = new ReaderAudioEngine()
|
||||
return engine
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type { ReaderChapter } from '../api/reader'
|
||||
|
||||
// 听书全局播放状态(刻意不持久化)。
|
||||
//
|
||||
// 真正的播放器是模块级单例(见 pages/reader/readerAudioEngine.ts),React 组件只
|
||||
// 读写这份状态、通过引擎的方法控制播放。这样音频不依附于任何路由:离开阅读页、
|
||||
// 换到别的书、甚至回到影视首页,播放都继续,由 Media Session 提供锁屏/通知栏控制。
|
||||
export type ReaderAudioStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
|
||||
|
||||
export interface ReaderAudioState {
|
||||
/** 当前音轨所属书籍;空串表示没有加载任何音轨。 */
|
||||
bookId: string
|
||||
bookName: string
|
||||
cover: string
|
||||
chapterIndex: number
|
||||
chapterTitle: string
|
||||
chapters: ReaderChapter[]
|
||||
/** 当前音轨地址(已签名,可直接交给 <audio> 或 hls.js)。 */
|
||||
track: string
|
||||
/** 该音轨由服务端转码,首次播放需要等待转码完成。 */
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
hasNext: boolean
|
||||
status: ReaderAudioStatus
|
||||
/** 加载/播放失败的可见原因(空串表示无错误)。 */
|
||||
error: string
|
||||
/** 当前播放位置(秒)。 */
|
||||
cur: number
|
||||
/** 总时长(秒),元数据未就绪时为 0。 */
|
||||
dur: number
|
||||
/** 播放倍速,与 readerSettings 的 audioSpeed 保持一致。 */
|
||||
speed: number
|
||||
timerActive: boolean
|
||||
/** 定时剩余秒数,0 表示未开启。 */
|
||||
timerLeft: number
|
||||
timerPreset: number
|
||||
}
|
||||
|
||||
const initialState: ReaderAudioState = {
|
||||
bookId: '',
|
||||
bookName: '',
|
||||
cover: '',
|
||||
chapterIndex: 0,
|
||||
chapterTitle: '',
|
||||
chapters: [],
|
||||
track: '',
|
||||
transcoding: false,
|
||||
openCredits: 0,
|
||||
closeCredits: 0,
|
||||
hasNext: false,
|
||||
status: 'idle',
|
||||
error: '',
|
||||
cur: 0,
|
||||
dur: 0,
|
||||
speed: 1,
|
||||
timerActive: false,
|
||||
timerLeft: 0,
|
||||
timerPreset: 0,
|
||||
}
|
||||
|
||||
export const useReaderAudioStore = create<ReaderAudioState>(() => ({ ...initialState }))
|
||||
|
||||
/** 引擎专用:整块重置回空闲态(停止播放时调用)。 */
|
||||
export function resetReaderAudio(): void {
|
||||
useReaderAudioStore.setState({ ...initialState })
|
||||
}
|
||||
@@ -33,20 +33,91 @@ export function getReaderTheme(themeId: string, night: boolean): { bg: string; t
|
||||
export type ReaderPageMode = 'page' | 'scroll'
|
||||
export type ReaderHomeMode = 'media' | 'reading'
|
||||
|
||||
/** 书架布局:网格封面 / 常规列表 / 紧凑列表(对应 legado AppConfig.bookshelfLayout)。 */
|
||||
export type ReaderShelfLayout = 'grid' | 'list' | 'compact'
|
||||
|
||||
/**
|
||||
* 书架排序(对应 legado AppConfig 的 bookSort):
|
||||
* recent=最近阅读、update=最近更新、name=书名、author=作者、mixed=综合、manual=手动顺序。
|
||||
*/
|
||||
export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed' | 'manual'
|
||||
|
||||
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
|
||||
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
|
||||
|
||||
/**
|
||||
* 漫画图片的显示尺寸档位(上下滚动模式用)。
|
||||
*
|
||||
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
|
||||
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
|
||||
* 和 comicDoublePage 一样只存本机、不参与账号同步。
|
||||
*
|
||||
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
|
||||
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
|
||||
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
|
||||
* - `long` 适应长边:整页完整可见,宽高都不超出视口
|
||||
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
|
||||
*/
|
||||
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
|
||||
|
||||
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
|
||||
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'width', label: '适应宽度' },
|
||||
{ id: 'height', label: '适应高度' },
|
||||
{ id: 'long', label: '适应长边' },
|
||||
{ id: 'original', label: '原图' },
|
||||
]
|
||||
|
||||
interface ReaderSettingsState {
|
||||
// 首页模式切换(影视 / 阅读)
|
||||
homeMode: ReaderHomeMode
|
||||
setHomeMode: (mode: ReaderHomeMode) => void
|
||||
|
||||
// ── 书架布局与展示(对应 legado 的书架设置)──
|
||||
/** 布局样式。 */
|
||||
shelfLayout: ReaderShelfLayout
|
||||
setShelfLayout: (layout: ReaderShelfLayout) => void
|
||||
/** 网格列数,0 为自适应。 */
|
||||
shelfGridColumns: number
|
||||
setShelfGridColumns: (columns: number) => void
|
||||
/** 排序方式。 */
|
||||
shelfSort: ReaderShelfSort
|
||||
setShelfSort: (sort: ReaderShelfSort) => void
|
||||
/** 是否显示未读章数徽标。 */
|
||||
shelfShowUnread: boolean
|
||||
setShelfShowUnread: (show: boolean) => void
|
||||
/** 是否显示「更新时间」一行(仅列表布局)。 */
|
||||
shelfShowUpdateTime: boolean
|
||||
setShelfShowUpdateTime: (show: boolean) => void
|
||||
|
||||
themeId: string
|
||||
setThemeId: (id: string) => void
|
||||
|
||||
night: boolean
|
||||
toggleNight: () => void
|
||||
setNight: (night: boolean) => void
|
||||
|
||||
pageMode: ReaderPageMode
|
||||
setPageMode: (mode: ReaderPageMode) => void
|
||||
|
||||
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
|
||||
comicDoublePage: boolean
|
||||
setComicDoublePage: (on: boolean) => void
|
||||
|
||||
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
|
||||
comicImageFit: ComicImageFit
|
||||
setComicImageFit: (fit: ComicImageFit) => void
|
||||
|
||||
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
|
||||
/**
|
||||
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
|
||||
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
|
||||
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
|
||||
*/
|
||||
searchScopeIds: string[]
|
||||
setSearchScopeIds: (ids: string[]) => void
|
||||
|
||||
fontSize: number
|
||||
setFontSize: (size: number) => void
|
||||
|
||||
@@ -80,15 +151,48 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
homeMode: 'media',
|
||||
setHomeMode: (mode) => set({ homeMode: mode }),
|
||||
|
||||
shelfLayout: 'grid',
|
||||
setShelfLayout: (shelfLayout) => set({ shelfLayout }),
|
||||
|
||||
shelfGridColumns: 0,
|
||||
setShelfGridColumns: (shelfGridColumns) =>
|
||||
set({ shelfGridColumns: Math.min(6, Math.max(0, Math.round(shelfGridColumns))) }),
|
||||
|
||||
shelfSort: 'recent',
|
||||
setShelfSort: (shelfSort) => set({ shelfSort }),
|
||||
|
||||
shelfShowUnread: true,
|
||||
setShelfShowUnread: (shelfShowUnread) => set({ shelfShowUnread }),
|
||||
|
||||
shelfShowUpdateTime: true,
|
||||
setShelfShowUpdateTime: (shelfShowUpdateTime) => set({ shelfShowUpdateTime }),
|
||||
|
||||
themeId: 'preset1',
|
||||
setThemeId: (themeId) => set({ themeId }),
|
||||
|
||||
night: false,
|
||||
toggleNight: () => set((s) => ({ night: !s.night })),
|
||||
setNight: (night) => set({ night }),
|
||||
|
||||
pageMode: 'page',
|
||||
setPageMode: (pageMode) => set({ pageMode }),
|
||||
|
||||
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
|
||||
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
|
||||
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
|
||||
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
|
||||
comicDoublePage: true,
|
||||
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
|
||||
|
||||
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
|
||||
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
|
||||
// 点「原图」。
|
||||
comicImageFit: 'default',
|
||||
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
|
||||
|
||||
searchScopeIds: [],
|
||||
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
|
||||
|
||||
fontSize: 20,
|
||||
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
|
||||
|
||||
@@ -110,3 +214,67 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
{ name: 'mebox-reader-settings' },
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
|
||||
*
|
||||
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
|
||||
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
|
||||
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
|
||||
* 取决于屏幕宽度和分辨率。
|
||||
*/
|
||||
export interface ReaderSettingsProfile {
|
||||
theme_id: string
|
||||
night: boolean
|
||||
page_mode: ReaderPageMode
|
||||
font_size: number
|
||||
line_height: number
|
||||
paragraph_spacing: number
|
||||
audio_speed: number
|
||||
audio_timer_minutes: number
|
||||
shelf_layout: ReaderShelfLayout
|
||||
shelf_grid_columns: number
|
||||
shelf_sort: ReaderShelfSort
|
||||
shelf_show_unread: boolean
|
||||
shelf_show_update_time: boolean
|
||||
}
|
||||
|
||||
/** 把本地状态整理成 /reader/profile 的载荷(只含参与同步的字段)。 */
|
||||
export function readerSettingsPayload(s: ReaderSettingsState): ReaderSettingsProfile {
|
||||
return {
|
||||
theme_id: s.themeId,
|
||||
night: s.night,
|
||||
page_mode: s.pageMode,
|
||||
font_size: s.fontSize,
|
||||
line_height: s.lineHeight,
|
||||
paragraph_spacing: s.paragraphSpacing,
|
||||
audio_speed: s.audioSpeed,
|
||||
audio_timer_minutes: s.audioTimerMinutes,
|
||||
shelf_layout: s.shelfLayout,
|
||||
shelf_grid_columns: s.shelfGridColumns,
|
||||
shelf_sort: s.shelfSort,
|
||||
shelf_show_unread: s.shelfShowUnread,
|
||||
shelf_show_update_time: s.shelfShowUpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用服务端的偏好覆盖本地状态(服务端是权威来源)。
|
||||
* 走 store 的 setter 而不是 setState,保证范围收敛只有一处实现。
|
||||
*/
|
||||
export function applyReaderSettingsProfile(p: ReaderSettingsProfile): void {
|
||||
const s = useReaderSettingsStore.getState()
|
||||
s.setThemeId(p.theme_id)
|
||||
s.setNight(p.night)
|
||||
s.setPageMode(p.page_mode)
|
||||
s.setFontSize(p.font_size)
|
||||
s.setLineHeight(p.line_height)
|
||||
s.setParagraphSpacing(p.paragraph_spacing)
|
||||
s.setAudioSpeed(p.audio_speed)
|
||||
s.setAudioTimerMinutes(p.audio_timer_minutes)
|
||||
s.setShelfLayout(p.shelf_layout)
|
||||
s.setShelfGridColumns(p.shelf_grid_columns)
|
||||
s.setShelfSort(p.shelf_sort)
|
||||
s.setShelfShowUnread(p.shelf_show_unread)
|
||||
s.setShelfShowUpdateTime(p.shelf_show_update_time)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
|
||||
|
||||
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`chapterGroups: ${name}`)
|
||||
}
|
||||
|
||||
check('默认组大小是 100', CHAPTER_GROUP_SIZE === 100)
|
||||
|
||||
// ── 切组 ──
|
||||
check('空目录没有分组', buildChapterGroups(0).length === 0)
|
||||
check('负数没有分组', buildChapterGroups(-5).length === 0)
|
||||
check('overflow 值没有分组', buildChapterGroups(Number.POSITIVE_INFINITY).length === 0)
|
||||
check('不足一组时只有一组', (() => {
|
||||
const g = buildChapterGroups(37)
|
||||
return g.length === 1 && g[0].start === 0 && g[0].end === 36 && g[0].label === '1-37'
|
||||
})())
|
||||
check('正好一组时只有一组', buildChapterGroups(100).length === 1)
|
||||
check('101 条切成两组', (() => {
|
||||
const g = buildChapterGroups(101)
|
||||
return g.length === 2 && g[1].start === 100 && g[1].end === 100 && g[1].label === '101-101'
|
||||
})())
|
||||
check('1317 条切成 14 组', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.length === 14 && g[13].label === '1301-1317' && g[13].end === 1316
|
||||
})())
|
||||
check('相邻组首尾相接不重叠', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.every((item, i) => i === 0 || item.start === g[i - 1].end + 1)
|
||||
})())
|
||||
check('组序号连续且从 0 起', buildChapterGroups(250).map((x) => x.index).join(',') === '0,1,2')
|
||||
check('自定义组大小生效', (() => {
|
||||
const g = buildChapterGroups(10, 4)
|
||||
return g.length === 3 && g[1].label === '5-8' && g[2].label === '9-10'
|
||||
})())
|
||||
check('非法组大小不产生分组', buildChapterGroups(10, 0).length === 0)
|
||||
|
||||
// ── 下标 → 组 ──
|
||||
check('第 0 条在第 0 组', chapterGroupIndexOf(0, 1317) === 0)
|
||||
check('第 99 条仍在第 0 组', chapterGroupIndexOf(99, 1317) === 0)
|
||||
check('第 100 条进入第 1 组', chapterGroupIndexOf(100, 1317) === 1)
|
||||
check('末条落在末组', chapterGroupIndexOf(1316, 1317) === 13)
|
||||
check('负数夹到第 0 组', chapterGroupIndexOf(-3, 1317) === 0)
|
||||
check('越界夹到末组', chapterGroupIndexOf(99999, 1317) === 13)
|
||||
check('非数字当作第 0 组', chapterGroupIndexOf(Number.NaN, 1317) === 0)
|
||||
check('空目录返回 0 组', chapterGroupIndexOf(5, 0) === 0)
|
||||
check('与切组分段一致', (() => {
|
||||
const g = buildChapterGroups(1317)
|
||||
return g.every((item) => chapterGroupIndexOf(item.start, 1317) === item.index)
|
||||
})())
|
||||
|
||||
console.log('chapterGroups.test.ts ok')
|
||||
@@ -0,0 +1,49 @@
|
||||
// 超长目录的「按段分组」纯逻辑。
|
||||
//
|
||||
// 起因:一本书动辄上千章(如 1317 章),目录面板只能一屏十来个条目地滑,
|
||||
// 想跳到中后段只能长按滚动条反复拖。这里把条目按固定大小切成若干组,
|
||||
// 面板顶部给出一个区间下拉(1-100 / 101-200 ...),选中即整段跳转。
|
||||
|
||||
/** 每组条目数。100 章一屏下拉里可读,长度也够短。 */
|
||||
export const CHAPTER_GROUP_SIZE = 100
|
||||
|
||||
export type ChapterGroup = {
|
||||
/** 组序号,0 起。 */
|
||||
index: number
|
||||
/** 组内起始条目下标(含)。 */
|
||||
start: number
|
||||
/** 组内结束条目下标(含)。 */
|
||||
end: number
|
||||
/** 下拉展示用区间文案,如 "1-100";末组不足 100 时自然变短("1301-1317")。 */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 把 total 个条目按 size 切成若干组;total 非正数时返回空数组。 */
|
||||
export function buildChapterGroups(total: number, size: number = CHAPTER_GROUP_SIZE): ChapterGroup[] {
|
||||
if (!Number.isFinite(total) || total <= 0) return []
|
||||
if (!Number.isFinite(size) || size <= 0) return []
|
||||
const count = Math.ceil(total / size)
|
||||
const groups: ChapterGroup[] = []
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const start = i * size
|
||||
const end = Math.min(total - 1, start + size - 1)
|
||||
groups.push({ index: i, start, end, label: `${start + 1}-${end + 1}` })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目下标落在第几组。用于两处:
|
||||
* 打开面板时定位到当前章节所在组;滚动时把下拉显示同步到可见区间。
|
||||
* 越界(负数 / 超过总数)一律夹到有效范围内,避免下拉出现空值。
|
||||
*/
|
||||
export function chapterGroupIndexOf(
|
||||
itemIndex: number,
|
||||
total: number,
|
||||
size: number = CHAPTER_GROUP_SIZE,
|
||||
): number {
|
||||
const groupCount = Math.ceil(Math.max(0, total) / size)
|
||||
if (groupCount <= 0) return 0
|
||||
const raw = Math.floor((Number.isFinite(itemIndex) ? itemIndex : 0) / size)
|
||||
return Math.min(Math.max(raw, 0), groupCount - 1)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`comicImageFit: ${name}`)
|
||||
}
|
||||
|
||||
// ── 是否由图片自身约束尺寸 ──
|
||||
const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
|
||||
check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
|
||||
check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
|
||||
check('height 是自尺寸档位', isSelfSizedComicFit('height'))
|
||||
check('long 是自尺寸档位', isSelfSizedComicFit('long'))
|
||||
check('original 是自尺寸档位', isSelfSizedComicFit('original'))
|
||||
check(
|
||||
'自尺寸档位与列宽档位互斥且覆盖全部取值',
|
||||
ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
|
||||
)
|
||||
|
||||
// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
|
||||
for (const fit of ['default', 'width'] as ComicImageFit[]) {
|
||||
const loaded = comicImageSizing(fit, 800, true)
|
||||
check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
|
||||
check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
|
||||
check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
|
||||
check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
|
||||
check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
|
||||
}
|
||||
|
||||
// ── height:一屏一页 ──
|
||||
check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
|
||||
check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
|
||||
check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
|
||||
check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
|
||||
check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
|
||||
|
||||
// ── long:整页完整可见 ──
|
||||
check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
|
||||
check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
|
||||
check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
|
||||
check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
|
||||
|
||||
// ── original:原始像素 1:1 ──
|
||||
check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
|
||||
check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
|
||||
check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
|
||||
|
||||
// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
|
||||
for (const fit of ['height', 'long'] as ComicImageFit[]) {
|
||||
check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
|
||||
}
|
||||
check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import type { ComicImageFit } from '../stores/readerSettings'
|
||||
|
||||
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 class/style(纯逻辑,便于单测)。
|
||||
//
|
||||
// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
|
||||
// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
|
||||
//
|
||||
// 两种控制方式:
|
||||
// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
|
||||
// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
|
||||
// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
|
||||
|
||||
/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
|
||||
const PLACEHOLDER_MIN_HEIGHT = '10rem'
|
||||
|
||||
export interface ComicImageSizing {
|
||||
className: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
|
||||
export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
|
||||
return fit === 'height' || fit === 'long' || fit === 'original'
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动模式下漫画图片的尺寸样式。
|
||||
*
|
||||
* @param fit 显示尺寸档位。
|
||||
* @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
|
||||
* (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
|
||||
* @param loaded 图片是否已加载完成,未完成时给个占位高度。
|
||||
*/
|
||||
export function comicImageSizing(
|
||||
fit: ComicImageFit,
|
||||
viewportHeight: number,
|
||||
loaded: boolean,
|
||||
): ComicImageSizing {
|
||||
const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
|
||||
// height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
|
||||
const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
|
||||
|
||||
switch (fit) {
|
||||
case 'height':
|
||||
// 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
|
||||
return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
|
||||
case 'long':
|
||||
// 整页完整可见:长边贴合视口,宽高都不超出
|
||||
return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
|
||||
case 'original':
|
||||
// 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
|
||||
return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
|
||||
default:
|
||||
// default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
|
||||
return { className: 'block w-full', style: placeholder }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 漫画「双页铺开」的纯逻辑与图片尺寸探测。
|
||||
//
|
||||
// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验
|
||||
// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、
|
||||
// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。
|
||||
//
|
||||
// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组,
|
||||
// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL,
|
||||
// 这里就自己探测并缓存(<img> 渲染时也会顺带回填,能省掉一次请求)。
|
||||
|
||||
/** 图片原始像素尺寸。 */
|
||||
export interface ImageSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** 宽高比达到这个值即视为「跨页宽图」。普通页约 0.6~0.7,跨页图约 1.3~1.5。 */
|
||||
const WIDE_RATIO = 1.1
|
||||
|
||||
/** 已探测到的尺寸,按 URL 缓存(null = 探测失败,当竖版处理),跨章节复用。 */
|
||||
const sizeCache = new Map<string, ImageSize | null>()
|
||||
/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */
|
||||
const inflight = new Map<string, Promise<ImageSize | null>>()
|
||||
|
||||
/** 渲染中的 <img> onLoad 顺带回填尺寸,省掉一次探测请求。 */
|
||||
export function rememberImageSize(src: string, width: number, height: number): void {
|
||||
if (!src || !width || !height) return
|
||||
sizeCache.set(src, { width, height })
|
||||
}
|
||||
|
||||
/** 该 URL 是否已经探测过(成功的记尺寸,失败的记 null)。 */
|
||||
export function hasImageSize(src: string): boolean {
|
||||
return sizeCache.has(src)
|
||||
}
|
||||
|
||||
/** 探测一张图片的原始尺寸;失败(404 / 被拦 / 解码失败)返回 null。 */
|
||||
export function loadImageSize(src: string): Promise<ImageSize | null> {
|
||||
const cached = sizeCache.get(src)
|
||||
if (cached !== undefined) return Promise.resolve(cached)
|
||||
const running = inflight.get(src)
|
||||
if (running) return running
|
||||
const task = new Promise<ImageSize | null>((resolve) => {
|
||||
const img = new Image()
|
||||
const settle = (size: ImageSize | null) => {
|
||||
sizeCache.set(src, size)
|
||||
inflight.delete(src)
|
||||
resolve(size)
|
||||
}
|
||||
img.onload = () =>
|
||||
settle(
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? { width: img.naturalWidth, height: img.naturalHeight }
|
||||
: null,
|
||||
)
|
||||
img.onerror = () => settle(null)
|
||||
img.src = src
|
||||
})
|
||||
inflight.set(src, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** 该图是否横跨两页(宽明显大于高)。尺寸未知时按竖版处理,宁可先配对。 */
|
||||
export function isWideImage(src: string): boolean {
|
||||
const size = sizeCache.get(src)
|
||||
if (!size) return false
|
||||
return size.width / size.height >= WIDE_RATIO
|
||||
}
|
||||
|
||||
/** 一「屏」要显示的图片序号:一张=独占的宽图/单页,两张=左右并排的一对。 */
|
||||
export type ComicSpread = number[]
|
||||
|
||||
/**
|
||||
* 把整章图片按双页铺开分组:连续的竖版页两两成对,宽图独占一组并打断前后配对。
|
||||
* 尺寸还没探测出来的图先按竖版参与配对,探测完成后再重新分组(见 useComicSpreads)。
|
||||
*/
|
||||
export function buildSpreads(srcs: string[]): ComicSpread[] {
|
||||
const spreads: ComicSpread[] = []
|
||||
let i = 0
|
||||
while (i < srcs.length) {
|
||||
if (isWideImage(srcs[i])) {
|
||||
spreads.push([i])
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
// 收集一段连续的竖版页,两两成对;落单的最后一张自己一屏(常见于章尾)
|
||||
const run: number[] = []
|
||||
while (i < srcs.length && !isWideImage(srcs[i])) {
|
||||
run.push(i)
|
||||
i += 1
|
||||
}
|
||||
for (let k = 0; k < run.length; k += 2) spreads.push(run.slice(k, k + 2))
|
||||
}
|
||||
return spreads
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片序号 → 所在屏序号。进度是按图片序号记的,翻页要换算成「当前在第几屏」,
|
||||
* 并且不管展开/收起双页都稳定(只由图片序号推出来,不额外存一屏序号)。
|
||||
*/
|
||||
export function buildSpreadIndexOf(spreads: ComicSpread[], total: number): number[] {
|
||||
const map = new Array<number>(Math.max(0, total)).fill(0)
|
||||
spreads.forEach((spread, si) => {
|
||||
for (const idx of spread) {
|
||||
if (idx >= 0 && idx < map.length) map[idx] = si
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isRenderableCover, needsCoverProxy } 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)'))
|
||||
|
||||
// needsCoverProxy:只有明文 http 的远端封面需要绕后端代理,否则 https 部署下
|
||||
// 会被混合内容策略拦掉(书源图床酷我等没有 https,浏览器升级请求必然失败)。
|
||||
check('http cover needs proxy', needsCoverProxy('http://img4.sycdn.kuwo.cn/star/albumcover/240/a.jpg'))
|
||||
check('uppercase http cover needs proxy', needsCoverProxy('HTTP://img4.sycdn.kuwo.cn/a.jpg'))
|
||||
check('padded http cover needs proxy', needsCoverProxy(' http://img4.sycdn.kuwo.cn/a.jpg '))
|
||||
check('https cover needs no proxy', !needsCoverProxy('https://imagev2.xmcdn.com/a.jpg'))
|
||||
check('protocol relative cover needs no proxy', !needsCoverProxy('//img.example.com/a.jpg'))
|
||||
check('local asset path needs no proxy', !needsCoverProxy('/api/reader/local/asset?b=1&p=2&s=3'))
|
||||
check('data uri needs no proxy', !needsCoverProxy('data:image/png;base64,iVBORw0KGgo='))
|
||||
check('empty needs no proxy', !needsCoverProxy(''))
|
||||
check('name that merely contains http needs no proxy', !needsCoverProxy('http 书籍'))
|
||||
|
||||
console.log('coverUrl.test.ts ok')
|
||||
@@ -0,0 +1,33 @@
|
||||
// 判断一个封面地址能否真正被浏览器渲染成图片。
|
||||
//
|
||||
// 书源在「这本书没有封面」时不一定返回空值:聚合类书源(如「光遇聚合」)的
|
||||
// 搜索请求地址本身是 data:;base64,... 参数信封,规则取值为空时会把它当地址
|
||||
// 兜底返回,于是 cover_url 变成一串 data: 文本。这类地址 media type 为空,
|
||||
// 浏览器按 text/plain 解码,必然显示破图。
|
||||
export function isRenderableCover(url?: string | null): boolean {
|
||||
const value = (url ?? '').trim()
|
||||
if (!value) return false
|
||||
|
||||
const lower = value.toLowerCase()
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://')) return true
|
||||
if (lower.startsWith('data:image/')) return true
|
||||
// 应用自身生成的相对地址(本地书封面 /api/reader/local/asset?...)以及
|
||||
// //host/path 这种协议相对地址。书源给出的相对地址同样落在这里,加载失败
|
||||
// 时由 <img> 的 onError 兜底回退成占位图。
|
||||
if (value.startsWith('/')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// needsCoverProxy 判断封面是否必须绕道后端图片代理才能显示。
|
||||
//
|
||||
// 明文 http 的封面在 https 部署下会被浏览器当作「混合内容」:浏览器先把 http
|
||||
// 自动升级成 https 再发请求,而不少书源图床(酷我 sycdn.kuwo.cn 等)根本没有
|
||||
// https,升级失败后图片被直接丢弃,界面上只剩占位图标。同一份书源在本地 http
|
||||
// 部署(127.0.0.1:8080)却一切正常,差异就出在这里。
|
||||
//
|
||||
// 其余地址不需要代理:https 本身是安全协议,data: 图片不经过网络,应用自身的
|
||||
// 相对地址天然同源。代理它们只会白白多一次服务端抓取。
|
||||
export function needsCoverProxy(url?: string | null): boolean {
|
||||
return (url ?? '').trim().toLowerCase().startsWith('http://')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { splitKindTags } from './kindTags.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`kindTags: ${name}`)
|
||||
}
|
||||
|
||||
const eq = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i])
|
||||
|
||||
check('空值返回空数组', splitKindTags(undefined).length === 0)
|
||||
check('空串返回空数组', splitKindTags('').length === 0)
|
||||
check('全空段返回空数组', splitKindTags(',,,').length === 0)
|
||||
// 聚合源的真实取值:只有状态/分类有值,其余为空
|
||||
check('丢掉空段', eq(splitKindTags('已完结,,,'), ['已完结']))
|
||||
check('保留有效段顺序', eq(splitKindTags('已完结,玄幻,'), ['已完结', '玄幻']))
|
||||
check('去掉段内空白', eq(splitKindTags(' 连载 , 都市 '), ['连载', '都市']))
|
||||
check('无逗号时整体作一段', eq(splitKindTags('有声小说'), ['有声小说']))
|
||||
// 更新时间段也是合法标签(legado 同样展示)
|
||||
check('保留更新时间段', eq(splitKindTags(',,有声小说,2018-12-11 22:01:22'), ['有声小说', '2018-12-11 22:01:22']))
|
||||
|
||||
console.log('kindTags.test.ts ok')
|
||||
@@ -0,0 +1,11 @@
|
||||
// 书源的 kind 规则普遍是逗号拼接的多段,例如聚合源的
|
||||
// `{{$.status}},{{$.score}},{{$.tags}},{{$.last_chapter_update_time}}`。
|
||||
//
|
||||
// 取不到值的段会是空串(如 "已完结,,,"),直接展示会变成 ",,有声小说," 这种噪音,
|
||||
// 所以按逗号切分后丢掉空段,只保留有效标签(legado 也是按段展示)。
|
||||
export function splitKindTags(kind?: string | null): string[] {
|
||||
return (kind ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ALL_GROUP_ID,
|
||||
UNGROUPED_GROUP_ID,
|
||||
attachBookToGroup,
|
||||
buildBookGroupTabs,
|
||||
dedupeBookGroups,
|
||||
detachBooksFromGroup,
|
||||
filterBooksByGroup,
|
||||
groupIdOfBook,
|
||||
normalizeBookGroups,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
} from './readerBookGroups'
|
||||
|
||||
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`readerBookGroups: ${name}`)
|
||||
}
|
||||
|
||||
const book = (id: string) => ({ id })
|
||||
|
||||
// ── 清洗 ──
|
||||
check('non-array → empty', normalizeBookGroups(null).length === 0)
|
||||
check('blank name dropped', normalizeBookGroups([{ name: ' ', book_ids: ['a'] }]).length === 0)
|
||||
check('name trimmed and ids deduped', (() => {
|
||||
const got = normalizeBookGroups([{ name: ' 科幻 ', book_ids: ['a', '', 'a', 'b'] }])
|
||||
return got.length === 1 && got[0].name === '科幻' && got[0].book_ids.join(',') === 'a,b'
|
||||
})())
|
||||
check('same name (case-insensitive) merged', (() => {
|
||||
const got = normalizeBookGroups([
|
||||
{ name: 'SciFi', book_ids: ['a'] },
|
||||
{ name: 'scifi', book_ids: ['b'] },
|
||||
])
|
||||
return got.length === 1 && got[0].book_ids.join(',') === 'a,b'
|
||||
})())
|
||||
|
||||
// ── 一书一组 ──
|
||||
const claimed = dedupeBookGroups([
|
||||
{ name: '一', book_ids: ['a', 'b'] },
|
||||
{ name: '二', book_ids: ['b', 'c'] },
|
||||
{ name: '三', book_ids: ['c'] },
|
||||
])
|
||||
check('dedupe keeps first claim', claimed[0].book_ids.join(',') === 'a,b')
|
||||
// 二 原本 [b,c]:b 被 一 抢走后仍留住 c;三 的 [c] 已被 二 认领,因此变空
|
||||
check('dedupe keeps the still-unclaimed member', claimed[1].book_ids.join(',') === 'c')
|
||||
check('dedupe empties the group whose books were all claimed', claimed[2].book_ids.length === 0)
|
||||
|
||||
// ── 过滤 ──
|
||||
const books = [book('a'), book('b'), book('c'), book('d')]
|
||||
const groups = [{ name: '科幻', book_ids: ['a', 'b'] }, { name: '在读', book_ids: ['c'] }]
|
||||
const ids = (list: { id: string }[]) => list.map((x) => x.id).join(',')
|
||||
|
||||
check('ALL returns everything', ids(filterBooksByGroup(books, groups, ALL_GROUP_ID)) === 'a,b,c,d')
|
||||
check('empty id behaves like ALL', ids(filterBooksByGroup(books, groups, '')) === 'a,b,c,d')
|
||||
check('group filter keeps members in input order', ids(filterBooksByGroup(books, groups, '科幻')) === 'a,b')
|
||||
check('unknown group → empty', filterBooksByGroup(books, groups, '不存在').length === 0)
|
||||
check('ungrouped returns unclaimed books', ids(filterBooksByGroup(books, groups, UNGROUPED_GROUP_ID)) === 'd')
|
||||
check('stale ids in a group are ignored', (() => {
|
||||
const withStale = [{ name: '科幻', book_ids: ['a', 'ghost'] }]
|
||||
return ids(filterBooksByGroup(books, withStale, '科幻')) === 'a'
|
||||
})())
|
||||
|
||||
// ── 分组栏 ──
|
||||
const noGroups = buildBookGroupTabs(books, [])
|
||||
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
|
||||
|
||||
const tabs = buildBookGroupTabs(books, groups)
|
||||
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
|
||||
check('全部 counts all books', tabs[0].count === 4)
|
||||
check('未分组 counts unclaimed', tabs[1].count === 1)
|
||||
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
|
||||
check('stale ids do not inflate counts', (() => {
|
||||
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
|
||||
return withStale[2].count === 1 && withStale[1].count === 3
|
||||
})())
|
||||
|
||||
// ── 排序与选中回落 ──
|
||||
check(
|
||||
'reorder follows the given order',
|
||||
reorderBookGroups(groups, ['在读', '科幻']).map((g) => g.name).join(',') === '在读,科幻',
|
||||
)
|
||||
check(
|
||||
'reorder appends unknown names (never drops)',
|
||||
reorderBookGroups(groups, ['在读']).map((g) => g.name).join(',') === '在读,科幻',
|
||||
)
|
||||
check('selected group falls back to ALL when removed', resolveSelectedGroupId(groups, '已删除') === ALL_GROUP_ID)
|
||||
check('selected group kept when it exists', resolveSelectedGroupId(groups, '科幻') === '科幻')
|
||||
check('ungrouped selection is preserved', resolveSelectedGroupId(groups, UNGROUPED_GROUP_ID) === UNGROUPED_GROUP_ID)
|
||||
|
||||
// ── 归组 ──
|
||||
const moved = attachBookToGroup(groups, 'c', '科幻')
|
||||
check('attach moves the book out of its old group', moved[1].book_ids.length === 0)
|
||||
check('attach appends to the target group', moved[0].book_ids.join(',') === 'a,b,c')
|
||||
check('attach to unknown group is a no-op', attachBookToGroup(groups, 'c', '不存在') === groups)
|
||||
check('detach returns the book to ungrouped', detachBooksFromGroup(groups, ['a'])[0].book_ids.join(',') === 'b')
|
||||
check('groupIdOfBook finds the owner', groupIdOfBook(groups, 'c') === '在读' && groupIdOfBook(groups, 'd') === '')
|
||||
|
||||
console.log('readerBookGroups.test.ts ok')
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { BookGroup } from '../api/reader'
|
||||
|
||||
// 书架分组的纯逻辑(对齐影视模块的 utils/libraryTags.ts)。
|
||||
//
|
||||
// 结构同构:组名 → 成员 ID 列表,「一本书只归一个组」,整份替换。
|
||||
// 差别在于书架多两个内置页签:全部 / 未分组(未分组不持久化,只是「没归任何组」的视图)。
|
||||
|
||||
/** 与后端 model.MaxBookGroupNameLen 保持一致。 */
|
||||
export const MAX_BOOK_GROUP_NAME_LENGTH = 24
|
||||
/** 与后端 model.MaxBookGroups 保持一致。 */
|
||||
export const MAX_BOOK_GROUPS = 50
|
||||
|
||||
/** 「全部」伪页签:不参与持久化,仅用于分组栏选中项。 */
|
||||
export const ALL_GROUP_ID = '__all__'
|
||||
/** 「未分组」伪页签:没有归入任何分组的书籍。 */
|
||||
export const UNGROUPED_GROUP_ID = '__ungrouped__'
|
||||
|
||||
export type BookGroupTab = {
|
||||
/** 选中的稳定标识:ALL_GROUP_ID / UNGROUPED_GROUP_ID / 分组名。 */
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
kind: 'all' | 'ungrouped' | 'group'
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mebox_reader_book_groups'
|
||||
const SELECTED_KEY = 'mebox_reader_book_group_selected'
|
||||
|
||||
/** 分组名去空白并按字符数截断,与后端清洗规则一致。 */
|
||||
export function normalizeBookGroupName(name: string): string {
|
||||
return Array.from(name.trim()).slice(0, MAX_BOOK_GROUP_NAME_LENGTH).join('').trim()
|
||||
}
|
||||
|
||||
export function normalizeBookGroups(raw: unknown): BookGroup[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: BookGroup[] = []
|
||||
const indexByName = new Map<string, number>()
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const name = normalizeBookGroupName(String((item as BookGroup).name ?? ''))
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
let pos = indexByName.get(key)
|
||||
if (pos === undefined) {
|
||||
if (out.length >= MAX_BOOK_GROUPS) break
|
||||
out.push({ name, book_ids: [] })
|
||||
pos = out.length - 1
|
||||
indexByName.set(key, pos)
|
||||
}
|
||||
const ids = (item as BookGroup).book_ids
|
||||
if (!Array.isArray(ids)) continue
|
||||
const seen = new Set(out[pos].book_ids)
|
||||
for (const id of ids) {
|
||||
const trimmed = String(id ?? '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out[pos].book_ids.push(trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 读取本地兜底缓存:接口不可用时分组栏仍然可用。 */
|
||||
export function readCachedBookGroups(): BookGroup[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
return normalizeBookGroups(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedBookGroups(groups: BookGroup[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (groups.length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
|
||||
} catch {
|
||||
// 私密模式等场景忽略存储失败。
|
||||
}
|
||||
}
|
||||
|
||||
export function readSelectedGroupId(): string {
|
||||
if (typeof window === 'undefined') return ALL_GROUP_ID
|
||||
try {
|
||||
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_GROUP_ID
|
||||
} catch {
|
||||
return ALL_GROUP_ID
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSelectedGroupId(groupId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
if (!groupId || groupId === ALL_GROUP_ID) {
|
||||
window.localStorage.removeItem(SELECTED_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(SELECTED_KEY, groupId)
|
||||
} catch {
|
||||
// 同上。
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadBookGroups(): Promise<BookGroup[]> {
|
||||
const { readerAPI } = await import('../api/reader')
|
||||
const remote = normalizeBookGroups(await readerAPI.getBookGroups())
|
||||
writeCachedBookGroups(remote)
|
||||
return remote
|
||||
}
|
||||
|
||||
export async function saveBookGroups(groups: BookGroup[]): Promise<BookGroup[]> {
|
||||
const { readerAPI } = await import('../api/reader')
|
||||
const saved = normalizeBookGroups(await readerAPI.setBookGroups(normalizeBookGroups(groups)))
|
||||
writeCachedBookGroups(saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛为「一本书只归一个组」:越靠前的分组优先,后面重复出现的书会被移除。
|
||||
* 与后端 dedupeBookGroups 的语义一致。
|
||||
*/
|
||||
export function dedupeBookGroups(groups: BookGroup[]): BookGroup[] {
|
||||
const claimed = new Set<string>()
|
||||
return groups.map((group) => {
|
||||
const bookIds: string[] = []
|
||||
for (const id of group.book_ids) {
|
||||
if (claimed.has(id)) continue
|
||||
claimed.add(id)
|
||||
bookIds.push(id)
|
||||
}
|
||||
return { ...group, book_ids: bookIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 分组名是否可用:非空、未超长、且(除自身外)没有重名。 */
|
||||
export function bookGroupNameError(name: string, groups: BookGroup[], exceptName?: string): string {
|
||||
const trimmed = normalizeBookGroupName(name)
|
||||
if (!trimmed) return '分组名不能为空'
|
||||
if (Array.from(name.trim()).length > MAX_BOOK_GROUP_NAME_LENGTH) {
|
||||
return `分组名最多 ${MAX_BOOK_GROUP_NAME_LENGTH} 个字`
|
||||
}
|
||||
const key = trimmed.toLowerCase()
|
||||
const selfKey = exceptName ? normalizeBookGroupName(exceptName).toLowerCase() : ''
|
||||
if (key !== selfKey && groups.some((group) => group.name.toLowerCase() === key)) {
|
||||
return '分组名已存在'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function findBookGroupIdByName(groups: BookGroup[], name: string): string {
|
||||
const key = normalizeBookGroupName(name).toLowerCase()
|
||||
if (!key) return ''
|
||||
return groups.find((group) => group.name.toLowerCase() === key)?.name ?? ''
|
||||
}
|
||||
|
||||
export function groupBookIds(groups: BookGroup[], groupId: string): string[] {
|
||||
if (!groupId || groupId === ALL_GROUP_ID || groupId === UNGROUPED_GROUP_ID) return []
|
||||
const group = groups.find((item) => item.name === groupId)
|
||||
return group ? group.book_ids : []
|
||||
}
|
||||
|
||||
/** 一本书所属的分组名;未归组返回空串。 */
|
||||
export function groupIdOfBook(groups: BookGroup[], bookId: string): string {
|
||||
for (const group of groups) {
|
||||
if (group.book_ids.includes(bookId)) return group.name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isBookInGroup(groups: BookGroup[], groupId: string, bookId: string): boolean {
|
||||
return groupBookIds(groups, groupId).includes(bookId)
|
||||
}
|
||||
|
||||
/** 所有已被某个分组认领的书籍 ID。 */
|
||||
export function groupedBookIds(groups: BookGroup[]): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const id of group.book_ids) out.add(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分组过滤书籍:`全部` 原样返回,`未分组` 返回没有被任何分组认领的书。
|
||||
* 只做成员筛选、保持输入顺序;展示顺序由调用方决定。
|
||||
*/
|
||||
export function filterBooksByGroup<T extends { id: string }>(
|
||||
books: T[],
|
||||
groups: BookGroup[],
|
||||
groupId: string,
|
||||
): T[] {
|
||||
if (!groupId || groupId === ALL_GROUP_ID) return books
|
||||
if (groupId === UNGROUPED_GROUP_ID) {
|
||||
const claimed = groupedBookIds(groups)
|
||||
return books.filter((book) => !claimed.has(book.id))
|
||||
}
|
||||
const ids = new Set(groupBookIds(groups, groupId))
|
||||
if (ids.size === 0) return []
|
||||
return books.filter((book) => ids.has(book.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
|
||||
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
|
||||
*/
|
||||
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
|
||||
const available = new Set(books.map((book) => book.id))
|
||||
const claimed = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const id of group.book_ids) {
|
||||
if (available.has(id)) claimed.add(id)
|
||||
}
|
||||
}
|
||||
const tabs: BookGroupTab[] = [
|
||||
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
|
||||
]
|
||||
if (groups.length === 0) return tabs
|
||||
tabs.push({
|
||||
id: UNGROUPED_GROUP_ID,
|
||||
name: '未分组',
|
||||
count: books.length - claimed.size,
|
||||
kind: 'ungrouped',
|
||||
})
|
||||
for (const group of groups) {
|
||||
tabs.push({
|
||||
id: group.name,
|
||||
name: group.name,
|
||||
count: group.book_ids.filter((id) => available.has(id)).length,
|
||||
kind: 'group',
|
||||
})
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
/**
|
||||
* 按给定分组名顺序重排:`orderedNames` 是期望顺序(通常来自拖拽结果)。
|
||||
* 未出现在顺序里的分组保持原相对顺序并追加到末尾,绝不丢失。
|
||||
*/
|
||||
export function reorderBookGroups(groups: BookGroup[], orderedNames: string[]): BookGroup[] {
|
||||
if (groups.length < 2) return groups
|
||||
const rank = new Map(orderedNames.map((name, index) => [name, index]))
|
||||
const ranked: BookGroup[] = []
|
||||
const rest: BookGroup[] = []
|
||||
for (const group of groups) {
|
||||
;(rank.has(group.name) ? ranked : rest).push(group)
|
||||
}
|
||||
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
|
||||
return [...ranked, ...rest]
|
||||
}
|
||||
|
||||
/** 选中的分组是否仍存在;被删除时回落到「全部」。 */
|
||||
export function resolveSelectedGroupId(groups: BookGroup[], selectedId: string): string {
|
||||
if (!selectedId || selectedId === ALL_GROUP_ID || selectedId === UNGROUPED_GROUP_ID) return selectedId || ALL_GROUP_ID
|
||||
return groups.some((group) => group.name === selectedId) ? selectedId : ALL_GROUP_ID
|
||||
}
|
||||
|
||||
/** 把一本书挂到分组下:先从原分组移除,保证一书一组。 */
|
||||
export function attachBookToGroup(groups: BookGroup[], bookId: string, groupName: string): BookGroup[] {
|
||||
const target = findBookGroupIdByName(groups, groupName)
|
||||
if (!target) return groups
|
||||
const next = groups.map((group) => ({
|
||||
...group,
|
||||
book_ids: group.book_ids.filter((id) => id !== bookId),
|
||||
}))
|
||||
return next.map((group) =>
|
||||
group.name === target ? { ...group, book_ids: [...group.book_ids, bookId] } : group,
|
||||
)
|
||||
}
|
||||
|
||||
/** 批量把书籍挂到分组下;整批只产生一次写入。 */
|
||||
export function attachBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
|
||||
const target = findBookGroupIdByName(groups, groupName)
|
||||
const moving = normalizeBookIds(bookIds)
|
||||
if (!target || moving.length === 0) return groups
|
||||
const movingSet = new Set(moving)
|
||||
const next = groups.map((group) => ({
|
||||
...group,
|
||||
book_ids: group.book_ids.filter((id) => !movingSet.has(id)),
|
||||
}))
|
||||
return next.map((group) =>
|
||||
group.name === target ? { ...group, book_ids: [...group.book_ids, ...moving] } : group,
|
||||
)
|
||||
}
|
||||
|
||||
/** 批量把书籍移出所有分组(即变为「未分组」)。 */
|
||||
export function detachBooksFromGroup(groups: BookGroup[], bookIds: string[]): BookGroup[] {
|
||||
const moving = new Set(normalizeBookIds(bookIds))
|
||||
if (moving.size === 0) return groups
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
book_ids: group.book_ids.filter((id) => !moving.has(id)),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 批量归类:groupName 为空表示移出所有分组。 */
|
||||
export function assignBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
|
||||
return groupName ? attachBooksToGroup(groups, bookIds, groupName) : detachBooksFromGroup(groups, bookIds)
|
||||
}
|
||||
|
||||
/** 去掉空白与重复,保留首次出现的顺序。 */
|
||||
export function normalizeBookIds(bookIds: unknown): string[] {
|
||||
if (!Array.isArray(bookIds)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of bookIds) {
|
||||
const trimmed = String(raw ?? '').trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out.push(trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { imageURL } from '../api/client'
|
||||
import { needsCoverProxy } from './coverUrl'
|
||||
|
||||
// readerCoverSrc 把书源给出的封面地址解析成 <img> 可以直接用的地址。
|
||||
//
|
||||
// 书源封面常挂在没有 https 的图床上(酷我 sycdn.kuwo.cn 等)。https 部署下直接
|
||||
// 引用这类明文 http 地址会被浏览器的混合内容策略拦掉,封面退化成占位图标;改由
|
||||
// 后端 /api/img 抓取后同源下发就没有这个问题。其余地址原样返回,不给本就能直连
|
||||
// 的封面增加一次服务端中转。
|
||||
export function readerCoverSrc(url?: string | null): string {
|
||||
const value = (url ?? '').trim()
|
||||
if (!value) return ''
|
||||
return needsCoverProxy(value) ? imageURL(value) : value
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readerAPI } from '../api/reader'
|
||||
import { isAuthenticated } from '../stores/auth'
|
||||
import {
|
||||
applyReaderSettingsProfile,
|
||||
readerSettingsPayload,
|
||||
useReaderSettingsStore,
|
||||
} from '../stores/readerSettings'
|
||||
|
||||
// 阅读器偏好(主题 / 排版 / 听书 / 书架展示)的跨设备同步。
|
||||
//
|
||||
// 服务端是权威来源,localStorage(zustand persist)退化成首屏缓存:
|
||||
// - 登录后 hydrateReaderSettings() 拉一次服务端偏好覆盖本地;
|
||||
// - 之后本地任何改动防抖回写服务端。
|
||||
// 该用户还没保存过时服务端返回 null,这时把本地现值推上去「播种」,
|
||||
// 这样升级前已经在本机调好的设置不会被重置成默认值。
|
||||
|
||||
/** 单次回写延迟:把连点字体 +/-、拖滑块这类连续操作合并成一次请求。 */
|
||||
const PUSH_DEBOUNCE_MS = 600
|
||||
|
||||
/** 拉取服务端期间抑制回写,避免把本地旧值当成用户改动推上去。 */
|
||||
let hydrating = false
|
||||
let pushTimer: number | null = null
|
||||
let subscribed = false
|
||||
|
||||
function schedulePush(): void {
|
||||
if (hydrating || !isAuthenticated()) return
|
||||
if (pushTimer !== null) window.clearTimeout(pushTimer)
|
||||
pushTimer = window.setTimeout(() => {
|
||||
pushTimer = null
|
||||
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
|
||||
readerAPI.saveReaderSettings(payload).catch(() => undefined)
|
||||
}, PUSH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 订阅本地偏好变化并防抖回写服务端;重复调用只生效一次。 */
|
||||
export function startReaderSettingsSync(): void {
|
||||
if (subscribed) return
|
||||
subscribed = true
|
||||
useReaderSettingsStore.subscribe(() => schedulePush())
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取当前账号的阅读器偏好并应用到本地。
|
||||
* 服务端异常 / 离线时保持本地值不动,等下一次改动再同步。
|
||||
*/
|
||||
export async function hydrateReaderSettings(): Promise<void> {
|
||||
if (!isAuthenticated()) return
|
||||
hydrating = true
|
||||
try {
|
||||
const remote = await readerAPI.getReaderSettings()
|
||||
if (remote) {
|
||||
applyReaderSettingsProfile(remote)
|
||||
} else {
|
||||
// 该账号还没有偏好记录:用本地现值播种,保证老用户的设置迁移过去
|
||||
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
|
||||
await readerAPI.saveReaderSettings(payload).catch(() => undefined)
|
||||
}
|
||||
} catch {
|
||||
// 保持本地缓存值,不打扰用户
|
||||
} finally {
|
||||
hydrating = false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user