Files
book-comic-library/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md

27 KiB
Raw Permalink Blame History

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 末尾)

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 末尾)
/** 页号 → 所在章下标;无章(扁平包或单组)恒 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
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

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 (
      <div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
        本页加载失败
        <button className={btn} onClick={() => setNonce((n) => n + 1)}>
          重试
        </button>
      </div>
    );
  if (!src)
    return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
  return (
    <img
      src={src}
      alt=""
      style={{ width }}
      className="block"
      onLoad={(e) => {
        const el = e.currentTarget;
        if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
      }}
    />
  );
}

export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
  const countQ = useQuery({
    queryKey: ["pages", book.id],
    queryFn: () => api.pageCount(book.pages_url ?? ""),
    enabled: !!book.pages_url,
    retry: 0,
  });
  const count = countQ.data?.count ?? 0;
  const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
  const [toc, setToc] = useState(false);
  const activeRow = useRef<HTMLButtonElement>(null);
  const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
  const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
  const boxRef = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(480);
  const [vh, setVh] = useState(600);
  const [top, setTop] = useState(0);
  const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
  const [, bump] = useState(0);
  const restored = useRef(false);
  const scrollRaf = useRef(0);
  const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
  const curRef = useRef(0);
  const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
  const shiftRaf = useRef(0);
  const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
  const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
  const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
  const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
  const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位

  const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
  const w = useMemo(
    () => chapterWin(chapters, count, ci, continuous, prefetch),
    [chapters, count, ci, continuous, prefetch],
  );
  const locked = !!chapters && chapters.length >= 2 && !continuous;

  // 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
  const roRef = useRef<ResizeObserver | null>(null);
  function attachBox(el: HTMLDivElement | null) {
    boxRef.current = el;
    roRef.current?.disconnect();
    roRef.current = null;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      setWidth(Math.min(el.clientWidth, MAX_W));
      setVh(el.clientHeight);
    });
    ro.observe(el);
    roRef.current = ro;
  }

  // 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
  useEffect(() => {
    if (restored.current || !count || !boxRef.current) return;
    restored.current = true;
    const p = Number(initialLocator?.page);
    if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false);
  }, [count, initialLocator, ph]);

  // 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
  useEffect(() => {
    if (pending.current === null) return;
    const el = boxRef.current;
    if (!el) return;
    const t = pending.current;
    pending.current = null;
    el.scrollTop = ph.offset(t);
  }, [ci, ph]);

  // 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
  function onScroll() {
    if (scrollRaf.current) return;
    scrollRaf.current = requestAnimationFrame(() => {
      scrollRaf.current = 0;
      const el = boxRef.current;
      if (!el) return;
      const t = el.scrollTop;
      setTop(t);
      if (count) {
        curRef.current = ph.pageAt(t + vh / 2);
        if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
        saver.report({ page: curRef.current }, (curRef.current + 1) / count);
      }
      clearTimeout(idle.current);
      idle.current = setTimeout(() => {
        for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
          fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
      }, 250);
    });
  }

  useEffect(
    () => () => {
      cancelAnimationFrame(scrollRaf.current);
      cancelAnimationFrame(shiftRaf.current);
      clearTimeout(idle.current);
    },
    [],
  );

  function applyShift(d: number) {
    shift.current += d;
    if (shiftRaf.current) return;
    shiftRaf.current = requestAnimationFrame(() => {
      shiftRaf.current = 0;
      const el = boxRef.current;
      if (el && shift.current) el.scrollTop += shift.current;
      shift.current = 0;
    });
  }

  const cur = ph.pageAt(top + vh / 2);

  useEffect(() => {
    if (toc) activeRow.current?.scrollIntoView({ block: "center" });
  }, [toc]);

  function scrollToPage(i: number, smooth: boolean) {
    const el = boxRef.current;
    if (!el) return;
    el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" });
  }

  // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径)
  function goPage(i: number, smooth = true) {
    const t = Math.max(0, Math.min(count - 1, i));
    chrome.show();
    if (locked && (t < w.start || t >= w.end)) {
      setCi(chapterIndexAt(chapters, t));
      pending.current = t;
      return;
    }
    scrollToPage(t, smooth);
  }

  // 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
  function turnScreen(d: number) {
    boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
  }

  // 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
  function onZoneClick(e: MouseEvent<HTMLDivElement>) {
    if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
    if (window.getSelection()?.toString()) return;
    const r = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - r.left) / r.width;
    if (x < 0.28) turnScreen(-1);
    else if (x > 0.72) turnScreen(1);
    else chrome.toggle();
  }

  // pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
  if (countQ.isPending && !!book.pages_url)
    return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
  if (countQ.isError || !book.page_url_fmt)
    return (
      <div className="grid h-full place-items-center p-8 text-center text-stone-500">
        无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
      </div>
    );

  const fmt: string = book.page_url_fmt;
  const [a0, b0] = ph.range(top, vh, 2);
  const a = Math.max(a0, w.start);
  const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
  const contentH = locked ? ph.offset(w.end) + vh : ph.total();

  function measure(i: number, h: number) {
    const el = boxRef.current;
    const topY = el ? el.scrollTop : 0;
    const oldEst = ph.estHeight;
    const topOfI = ph.offset(i);
    const d = ph.set(i, h);
    if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
    // 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
    if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
      const n = ph.setEst(h, topY);
      if (el && n) applyShift(n * (h - oldEst));
      ratio.current = h / width;
    }
    bump((x) => x + 1);
  }

  return (
    <div
      tabIndex={0}
      aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
      className="relative h-full"
      onKeyDown={(e) => {
        if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
        else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
        else return;
        e.preventDefault();
      }}
    >
      <div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
        <div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
          {Array.from({ length: Math.max(0, b - a) }, (_, k) => {
            const i = a + k;
            return (
              <div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
                <PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
              </div>
            );
          })}
          {locked && (
            <div
              className="absolute left-0 grid w-full place-items-center"
              style={{ top: ph.offset(w.end), height: vh }}
            >
              {w.end >= count ? (
                <span className="text-sm text-stone-500">— 全书完 —</span>
              ) : (
                <button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
                  本章完 · 下一章 →
                </button>
              )}
            </div>
          )}
          {locked && w.mountEnd > w.end && (
            // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
            <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
              {Array.from({ length: w.mountEnd - w.end }, (_, k) => {
                const i = w.end + k;
                return (
                  <div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
                    <PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>
      {chrome.on ? (
        <div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
          <div className="flex items-center gap-3">
            <input
              type="range"
              min={0}
              max={Math.max(0, count - 1)}
              value={scrub ?? cur}
              onChange={(e) => {
                const v = Number(e.target.value);
                setScrub(v);
                goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
              }}
              onPointerUp={() => setScrub(null)}
              onPointerCancel={() => setScrub(null)}
              onBlur={() => setScrub(null)}
              className="rd-range"
              aria-label="页码跳转"
            />
            <span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
              {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
            </span>
          </div>
          <div className="mt-1 flex items-center gap-2 pb-1 text-sm">
            <button
              className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
              aria-pressed={continuous}
              onClick={() => setContinuous(!continuous)}
            >
              连读{continuous ? "·开" : "·关"}
            </button>
            {locked && (
              <button
                className="rd-btn"
                aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
                onClick={() => setPrefetch((p) => (p + 1) % 4)}
              >
                预读 {prefetch} 章
              </button>
            )}
            {bm.btn}
            {chapters && (
              <>
                <button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
                  目录
                </button>
                <span className="text-xs tabular-nums opacity-60">
                  {ci + 1}/{chapters.length}
                </span>
                <button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
                  ← 上一章
                </button>
                <button
                  className="rd-btn"
                  disabled={ci === chapters.length - 1}
                  onClick={() => goPage(chapters[ci + 1].start)}
                >
                  下一章 →
                </button>
              </>
            )}
          </div>
        </div>
      ) : (
        <div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
          <span>
            {cur + 1}/{count}
          </span>
          <span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
        </div>
      )}
      {toc && chapters && (
        <>
          <div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
          <nav
            id="cbz-toc"
            aria-label="章节目录"
            className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
          >
            <ul className="space-y-0.5">
              {chapters.map((c, i) => (
                <li key={i}>
                  <button
                    ref={i === ci ? activeRow : undefined}
                    className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
                    onClick={() => {
                      goPage(c.start);
                      setToc(false);
                    }}
                  >
                    {i + 1}. {c.title}
                  </button>
                </li>
              ))}
            </ul>
          </nav>
        </>
      )}
      {bm.panel}
    </div>
  );
}
  • Step 2: 静态检查 + 单测全绿

Run: docker exec -w /app booklib_web_1 npm run -s check Expected: tsc 无错误、vitest 全绿、vite build 成功。若 tsc 报 chapters! 收窄或 unused import,就地修正后重跑。

  • Step 3: 追加 changelog(docs/CHANGELOG_web.md,插入到 ### Added / 新增 之后的最上方,与下一条之间留一个空行;英中文案见 spec《文档 / Docs》节,内容如下)
- 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 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
  • Step 4: Commit
git add frontend/src/readers/CbzReader.tsx docs/CHANGELOG_web.md
git commit -m "feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes"

Task 3: 浏览器验收(真实运行环境点验)

Files:

  • 无新文件;发现问题就地回改 Task 1/2 的文件并补 commit。

Interfaces:

  • Consumes: Task 2 产物;dev 栈(deploy/docker-compose.dev.yml,web 热更新无需重建)。

  • Step 1: 确认 dev 栈在跑(docker compose -f deploy/docker-compose.dev.yml ps;未跑则 up -d),打开 http://localhost:5173(或 compose 映射端口),登录。

  • Step 2: 分章包锁章验收——打开一本按目录分章的漫画:向下滚到本章底部出现「本章完 · 下一章」卡片且继续滚不进入下一章;点卡片/下一章秒开无白屏(预读 2 章生效);「预读」按钮循环 0–3 且刷新后记忆。

  • Step 3: 连读验收——点「连读」变「连读·开」:滚动可直接滑过章界进下章,目录计数随滚动走;刷新页面开关状态保持。

  • Step 4: 定位类入口验收——拖滑条到别章页 = 跳章成功;书签 seek 跳他章;重进书恢复到他章原位;点按左右缘/←→ 翻一屏(非翻一张图)。

  • Step 5: 扁平包回归——无目录的漫画:工具栏无连读/预读按钮、正常滚动到底只有全书末尾(无章末卡片);图片加载失败重试可用。

  • Step 6: 移动端手感抽查——窄屏(DevTools 手机模拟)滚到章底回弹正常、无逐帧跳动;console 无 React 报错。

  • Step 7: 全量回归收尾——docker exec -w /app booklib_web_1 npm run -s check 绿;有修复则 git add -A && git commit -m "fix(cbz): <具体现象>"。


Self-Review 记录

  1. Spec 覆盖:模式移除✓(Task 2 删除三档)、锁章滚动边界✓(contentH=章末+卡片)、翻屏✓(turnScreen)、章末卡片✓、滑条/书签/目录跨章=显式跳章✓(goPage 统一入口)、预读隐藏层✓(mountEnd + overflow:hidden 层)、步进器/持久化✓、扁平包✓(chapterWin 全书 + 按钮隐藏)、恢复进度✓(goPage 路径)、量高学习保留✓(measure)、changelog✓。
  2. 占位符:无 TBD/similar-to;测试与实现代码完整。
  3. 类型一致:Task 1 导出 chapterIndexAt(chs,page) / chapterWin(chs,count,ci,continuous,prefetch) 与 Task 2 import 一致;ChapterWindow 字段名与 Task 2 使用一致。