feat(cbz): adapt reader to real archive structure — PageIndex skips macOS junk (__MACOSX/ and ._ AppleDouble were polluting pages with 163-byte blacks), pages endpoint derives chapters[{title,start}] from folder layout, imageless zip now lands state=error instead of an empty reader, CbzReader gains 目录 sidebar + prev/next chapter (TextReader pattern, rd-* classes); pagesidx cache key bumped for one-time invalidation; e2e on 311MB 調教開關:第二季.zip: 15048→7524 real pages, 52 chapters, all JPEGs

This commit is contained in:
2026-09-08 00:22:13 +08:00
parent 7d650107d2
commit b8da9fd92e
9 changed files with 216 additions and 3 deletions
+2 -1
View File
@@ -172,7 +172,8 @@ export const api = {
},
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
pageCount: (pagesUrl: string) =>
apiFetch<{ count: number; chapters?: { title: string; start: number }[] | null }>(pagesUrl),
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
+54
View File
@@ -84,6 +84,9 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
retry: 0,
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
const [toc, setToc] = useState(false);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id);
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
@@ -168,6 +171,10 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
}
const cur = ph.pageAt(top + vh / 2);
const ci = chapters ? chapters.reduce((acc, c, i) => (c.start <= cur ? i : acc), 0) : 0;
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
function jump(i: number, smooth = true) {
const el = boxRef.current;
@@ -284,6 +291,26 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
>
{MODE_LABEL[mode]}
</button>
{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={() => jump(chapters[ci - 1].start)}>
← 上一章
</button>
<button
className="rd-btn"
disabled={ci === chapters.length - 1}
onClick={() => jump(chapters[ci + 1].start)}
>
下一章 →
</button>
</>
)}
</div>
</div>
) : (
@@ -294,6 +321,33 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
<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={() => {
jump(c.start);
setToc(false);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
</nav>
</>
)}
</div>
);
}