diff --git a/.gitignore b/.gitignore index fd343e5..46229b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env .superpowers/ tasks/ +.playwright*/ +frontend/tsconfig.tsbuildinfo diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 335e890..6d7f398 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,11 +10,14 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 -- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices. -- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。 +- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone. +- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。 -- Folder-structured comics gain a 目录 sidebar in the CBZ reader: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI. -- 按目录组织的漫画在 CBZ 阅读器新增「目录」侧栏:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。 +- Bookmarks in all four readers: in the 书签 tab of the left navigation drawer, 加 captures the current position (page / chapter+offset / EPUB CFI) with an optional note; clicking a row jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices. +- 四类阅读器新增书签:在左侧导航抽屉的「书签」选项卡内点「加」把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;点条目即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。 + +- Folder-structured comics gain a 目录 tab in the CBZ reader's navigation drawer: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI. +- 按目录组织的漫画在 CBZ 阅读器导航抽屉内新增「目录」选项卡:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。 - Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request. - 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。 @@ -28,13 +31,39 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Changed / 变更 +- Admin polish: 库管理 is now a table styled like 用户管理 (scan/upload per row, drag-to-row upload kept); both admin pages moved out of the sidebar and bottom tabs into a 系统管理 group under the avatar menu; admins can upload files straight from a library's shelf view via a new 上传文件 button next to 扫描此库. +- 后台入口统一:库管理改为用户管理同款表格(行内扫描/上传,仍支持拖文件到行);两个管理页从侧边栏与移动端底 Tab 收进头像菜单的「系统管理」分组;书架进入某个库时,管理员可在「扫描此库」旁直接「上传文件」。 + +- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar. +- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。 + +- Reader navigation tightened into one left-side drawer opened by a single 导航 toolbar button: 目录 and 书签 are now tabs of a sidebar shared by all four readers, following the reader theme's colors; the button lands on 目录 when a TOC exists, otherwise straight on bookmarks (flat archives, EPUB, PDF show no tab bar); the separate right-side bookmark panel and the per-reader TOC popovers are gone. +- 阅读器导航收紧为统一左侧抽屉,工具条只留一个「导航」按钮:目录与书签成为四个阅读器共用侧边栏内的两个选项卡,配色跟随阅读器主题;有目录时默认落在「目录」,否则直达书签(平铺包、EPUB、PDF 不显示选项卡条);原右侧书签面板与各阅读器自绘目录浮层移除。 + +- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next. +- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。 + +- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with a pages/size caption, color-coded format badges, in-cover progress bars, staggered entrance animations, skeleton loading, and dedicated empty/error/searching states; 继续阅读 is now a horizontal resume strip (cover + progress + library); login got a clean brand card; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse. +- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),封面下方显示页数/大小、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);登录页升级为主视觉卡片;Toast 带动画与图标;无封面书籍以衬线首字占位,不再无限脉冲。 + - .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter. - .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。 - Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page. - 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 +### Removed / 移除 + +- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading. +- 阅读器顶栏右上角不再提供 admin「删除」按钮:删除入口仅保留在书架封面卡片的操作菜单中(带确认对话框),避免阅读时误触翻页区旁的破坏性删除。 + ### Fixed / 修复 +- Library upload now reports the real file count: the success toast read the live `FileList` after the file input had been reset (always 0, e.g. "已上传 0 个文件"), making successful uploads look broken while waiting for the scanner; the list is snapshotted before uploading. +- 库管理上传完成提示不再恒为「已上传 0 个文件」:此前成功提示在清空 file input 之后读取其活引用 FileList 的长度(始终为 0),上传明明成功却像没生效;现在在上传开始前对文件列表做快照,计数与遍历都用快照。 + +- Comic continuous-scroll mode is contiguous again on phones: the size observer used to miss the scroll container (it was attached while the reader still showed "loading", so the box didn't exist), leaving page geometry computed from a stale 480px default width while pages rendered at the real (e.g. 390px) width — every page gained a constant dead gap. The observer now attaches when the box actually mounts; additionally, mobile address-bar show/hide no longer rebuilds the scroll geometry in continuous/whole-page modes, which used to wipe measured page heights and re-trigger a burst of image requests. +- 手机端漫画「连读」模式页面不再上下脱节:尺寸监听器此前在阅读器还在「加载中」时就尝试挂载(滚动容器尚不存在),导致监听从未生效,页高按过时的 480px 默认宽计算而实际按手机真实宽度(如 390px)渲染,每页之间出现恒定空白断层;现改为容器真正挂载时再接管。另外,手机地址栏收展在连读/整页模式下不再重建滚动几何——此前会清空全部实测页高并引发一波图片重复请求。 + - Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch. - 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。 - .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM. diff --git a/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md new file mode 100644 index 0000000..3ea8e7f --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md @@ -0,0 +1,594 @@ +# CBZ 章节化连续阅读(锁章 + 预读)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 漫画一章内所有图视为连续整章内容,默认滚动锁在章末(只能按钮切章),可开连读跨章,并预渲染其后 N 章(0–3,默认 2)使切章零白屏;移除 连读/整页/适高 三档模式。 + +**Architecture:** 纯前端改动。`lib/virt.ts` 新增两个纯函数(章节窗口计算)供单测全覆盖;`CbzReader.tsx` 重构为「章节窗口 + 屏翻页 + 隐藏预读层」,`PageHeights` 虚拟列表模型不动。无后端/API 改动。 + +**Tech Stack:** React 18 + TypeScript + Tailwind(rd-* 类)、vitest、vite。 + +Spec: `docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md` + +## Global Constraints + +- 分支 `feat/ui-redesign`(当前已在此分支,勿切 master 提交)。 +- 测试/构建在容器内跑:`docker exec -w /app booklib_web_1 npm run -s check`(= tsc --noEmit + vitest + vite build);单文件测试 `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`。 +- localStorage 键:`cbz-continuous`("1"/"0",默认关)、`cbz-prefetch`(0–3 整数,非法回退 2);旧键 `cbz-mode` 废弃不再读写。 +- 用户可见变更必须记 `docs/CHANGELOG_web.md`(英中各一行、相邻行、不同条目间空行),不重复进 `docs/CHANGELOG.md`。 +- 全书页号语义不变:进度/书签 locator `{page}`、滑条、页码 HUD 均为全书页,不改后端。 +- 容器宽上限 `MAX_W = 720` 保留;扁平包(`chapters` 为 null 或长度 < 2)= 整本一章。 + +--- + +### Task 1: `lib/virt.ts` 章节窗口纯函数(TDD) + +**Files:** +- Modify: `frontend/src/lib/virt.ts`(文件末尾追加) +- Test: `frontend/test/virt.test.ts`(文件末尾追加) + +**Interfaces:** +- Consumes: 无(新代码)。 +- Produces: + - `chapterIndexAt(chs: { start: number }[] | null, page: number): number` — 页所在章下标;无章/单章恒 0;page 小于首章 start 归 0。 + - `chapterWin(chs: { start: number }[] | null, count: number, ci: number, continuous: boolean, prefetch: number): ChapterWindow`,`interface ChapterWindow { start: number; end: number; mountEnd: number }`(start 含、end 不含;连读/扁平 → 全书;锁章 → 本章窗口,mountEnd 含 prefetch 章)。Task 2 按此签名消费。 + +- [ ] **Step 1: 写失败测试**(追加到 `frontend/test/virt.test.ts` 末尾) + +```ts +import { chapterIndexAt, chapterWin } from "../src/lib/virt"; // 与文件顶部 import 合并 + +const CH4 = [{ start: 0 }, { start: 10 }, { start: 20 }, { start: 30 }]; + +it("chapterIndexAt:无章/单章恒 0;页号归章;首章 start 之前的散页归 0", () => { + expect(chapterIndexAt(null, 7)).toBe(0); + expect(chapterIndexAt([{ start: 0 }], 7)).toBe(0); + expect(chapterIndexAt(CH4, 0)).toBe(0); + expect(chapterIndexAt(CH4, 9)).toBe(0); + expect(chapterIndexAt(CH4, 10)).toBe(1); + expect(chapterIndexAt(CH4, 999)).toBe(3); + expect(chapterIndexAt([{ start: 5 }, { start: 10 }], 3)).toBe(0); // 根散页 +}); + +it("chapterWin:扁平/连读 = 全书窗口", () => { + expect(chapterWin(null, 40, 0, false, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 }); + expect(chapterWin(CH4, 40, 2, true, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 }); +}); + +it("chapterWin:锁章窗口 = 本章;mountEnd 按预读章数延伸、夹到 count", () => { + expect(chapterWin(CH4, 40, 1, false, 0)).toEqual({ start: 10, end: 20, mountEnd: 20 }); + expect(chapterWin(CH4, 40, 1, false, 1)).toEqual({ start: 10, end: 20, mountEnd: 30 }); + expect(chapterWin(CH4, 40, 1, false, 2)).toEqual({ start: 10, end: 20, mountEnd: 40 }); + expect(chapterWin(CH4, 40, 1, false, 3)).toEqual({ start: 10, end: 20, mountEnd: 40 }); // 越界夹住 + expect(chapterWin(CH4, 40, 3, false, 2)).toEqual({ start: 30, end: 40, mountEnd: 40 }); // 末章 +}); + +it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefetch 负数按 0", () => { + expect(chapterWin(CH4, 40, 9, false, 1)).toEqual({ start: 30, end: 40, mountEnd: 40 }); + expect(chapterWin(CH4, 40, -2, false, 1)).toEqual({ start: 0, end: 10, mountEnd: 20 }); + expect(chapterWin([{ start: 5 }, { start: 10 }], 20, 0, false, 0)).toEqual({ start: 0, end: 10, mountEnd: 10 }); + expect(chapterWin(CH4, 40, 1, false, -1)).toEqual({ start: 10, end: 20, mountEnd: 20 }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts` +Expected: FAIL —「chapterIndexAt is not exported」/ import 解析错误。 + +- [ ] **Step 3: 实现**(追加到 `frontend/src/lib/virt.ts` 末尾) + +```ts +/** 页号 → 所在章下标;无章(扁平包或单组)恒 0;首章 start 之前的散页归首章。 */ +export function chapterIndexAt(chs: { start: number }[] | null, page: number): number { + if (!chs || chs.length < 2) return 0; + return chs.reduce((acc, c, i) => (c.start <= page ? i : acc), 0); +} + +export interface ChapterWindow { + start: number; // 窗口首页(含)——滚动/挂载下界 + end: number; // 窗口末页(不含)——锁章时的滚动边界 + mountEnd: number; // 挂载上界(含预读章),恒 ≤ count 且 ≥ end +} + +/** 阅读窗口:连读或扁平包 = 全书;锁章 = 本章滚动、本章 + prefetch 章挂载。 */ +export function chapterWin( + chs: { start: number }[] | null, + count: number, + ci: number, + continuous: boolean, + prefetch: number, +): ChapterWindow { + if (!chs || chs.length < 2 || continuous) return { start: 0, end: count, mountEnd: count }; + const c = Math.max(0, Math.min(ci, chs.length - 1)); + const end = chs[c + 1]?.start ?? count; + return { + start: c === 0 ? 0 : chs[c].start, + end, + mountEnd: chs[c + 1 + Math.max(0, prefetch)]?.start ?? count, + }; +} +``` + +- [ ] **Step 4: 跑测试确认通过(含既有 virt 用例回归)** + +Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts` +Expected: 全部 PASS(既有 4 条 + 新增 4 条)。 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/virt.ts frontend/test/virt.test.ts +git commit -m "feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests" +``` + +--- + +### Task 2: CbzReader 重构为章节窗口模型 + +**Files:** +- Modify: `frontend/src/readers/CbzReader.tsx`(整文件替换,代码见 Step 1) +- Modify: `docs/CHANGELOG_web.md`(Step 3) +- Test: 无新增组件测试(既有约定:reader UI 走 `npm run check` + 真实浏览器点验) + +**Interfaces:** +- Consumes: Task 1 的 `chapterIndexAt` / `chapterWin`;既有 `PageHeights`、`fetchObjectUrl`、`useProgressSaver`、`useBookmarks`、`api.pageCount`(`{count, chapters: {title,start}[] | null}`)、`chrome`(ReaderProps)。 +- Produces: 重写后的默认导出 `CbzReader(props: ReaderProps)`;删除 `PageMode/MODES/MODE_LABEL/useStoredMode/fitH` 等全部旧模式符号(无外部引用者)。 + +- [ ] **Step 1: 用以下完整内容替换 `frontend/src/readers/CbzReader.tsx`** + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { api, formatPageUrl } from "../api/client"; +import { btn } from "../components/ui"; +import { useBookmarks } from "../components/Bookmarks"; +import { fetchObjectUrl } from "../lib/authImage"; +import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; +import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt"; + +const MAX_W = 720; +const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅) + +function useStoredBool(key: string, def: boolean) { + const [v, setV] = useState(() => { + const s = localStorage.getItem(key); + return s === null ? def : s === "1"; + }); + useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]); + return [v, setV] as const; +} + +function useStoredPrefetch() { + const [n, setN] = useState(() => { + const v = Number(localStorage.getItem("cbz-prefetch")); + return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2; + }); + useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]); + return [n, setN] as const; +} + +function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) { + const [src, setSrc] = useState(""); + const [failed, setFailed] = useState(false); + const [nonce, setNonce] = useState(0); + useEffect(() => { + let dead = false; + setSrc(""); + setFailed(false); + fetchObjectUrl(url) + .then((s) => !dead && setSrc(s)) + .catch(() => !dead && setFailed(true)); + return () => { + dead = true; + }; + }, [url, nonce]); + if (failed) + return ( +
{`当前筛选:${libName}`}
} + + {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && ( +
+
{r.title || "已删除的书"}
+进度已保留
+{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}
+{l.root_path}
+还没有库,用上方表单创建。
} +