From 32b9d3a136f749cf7e916cf066bb9b374a1f7dfa Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 13:05:40 +0800 Subject: [PATCH 01/34] =?UTF-8?q?feat(ui):=20content-first=20shelf=20redes?= =?UTF-8?q?ign=20=E2=80=94=20responsive=20cover=20grid,=20hover=20overlays?= =?UTF-8?q?,=20format=20badges,=20skeletons,=20sticky=20blurred=20top=20ba?= =?UTF-8?q?r,=20brand=20login,=20SVG=20icon=20set,=20animated=20toasts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 + frontend/src/components/Cover.tsx | 32 +++- frontend/src/components/Toaster.tsx | 8 +- frontend/src/components/TopBar.tsx | 73 ++++++--- frontend/src/components/icons.tsx | 122 +++++++++++++++ frontend/src/components/ui.ts | 39 ++++- frontend/src/index.css | 11 ++ frontend/src/pages/Login.tsx | 54 ++++++- frontend/src/pages/Reader.tsx | 14 +- frontend/src/pages/Shelf.tsx | 228 +++++++++++++++++++--------- frontend/test/ui.test.ts | 12 ++ 11 files changed, 468 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/test/ui.test.ts diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 335e890..6b6a5cc 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -28,6 +28,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Changed / 变更 +- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with hover overlays for pages/size, 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); library filter became a segmented pill bar with an icon search field in a sticky blurred top bar (brand mark, admin nav pills, user chip, icon logout); login got a brand card with icon-adorned inputs; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse. +- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),悬停显示页数/大小浮层、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);库筛选变成分段 pill,搜索框带图标并移入毛玻璃吸顶顶栏(品牌标、管理导航 pill、用户胶囊、图标退出);登录页升级为主视觉卡片与带图标输入框;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. diff --git a/frontend/src/components/Cover.tsx b/frontend/src/components/Cover.tsx index aa5a76b..81a5cc1 100644 --- a/frontend/src/components/Cover.tsx +++ b/frontend/src/components/Cover.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import type { Book } from "../api/types"; import { fetchObjectUrl } from "../lib/authImage"; -import { btn } from "./ui"; +import { btn, formatBadge } from "./ui"; +import { IconAlert } from "./icons"; export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false }); @@ -27,14 +28,29 @@ export function useAuthedImage(url: string | undefined): { src: string; failed: export function Cover({ book, className }: { book: Book; className?: string }) { const { src, failed, retry } = useAuthedImage(book.cover_url); - if (failed) + if (!book.cover_url || failed) { return ( -
- +
+ {Array.from(book.title)[0] ?? "?"} + {book.format} + {failed && ( + + )}
); - if (!src) return
; - return ; + } + if (!src) return
; + return ( + {`《${book.title}》封面`} + ); } diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index a569d6d..d5e29af 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; +import { IconAlert, IconCheck } from "./icons"; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; @@ -31,10 +32,13 @@ export function Toaster() {
+ {t.kind === "ok" ? : } {t.text}
))} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index bd5687f..9f8dea3 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,41 +1,64 @@ import type { ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link, NavLink } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; -import { btn } from "./ui"; +import { IconBooks, IconFolder, IconLogout, IconUser } from "./icons"; +import { btnGhost } from "./ui"; export function TopBar({ right }: { right?: ReactNode }) { const { user, isAdmin, logout } = useAuth(); return ( -
- - BookLib +
+ + + + + + BookLib + {isAdmin && ( - - 用户 - + )} - {isAdmin && ( - - 库管理 - - )} -
+
{right} - - {user?.username} - {isAdmin ? "(admin)" : ""} + + + {Array.from(user?.username ?? "?")[0]?.toUpperCase()} + + {user?.username} + {isAdmin && admin} -
); } + +function TabLink({ to, children }: { to: string; children: ReactNode }) { + return ( + + `flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 ${ + isActive ? "bg-white/[0.09] text-stone-100 ring-1 ring-white/10" : "text-stone-400 hover:bg-white/[0.06] hover:text-stone-100" + }` + } + > + {children} + + ); +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..8a0782c --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,122 @@ +import type { SVGProps } from "react"; + +// 统一 1.7px 描边的线性图标(Phosphor 风格),尺寸由外部 className 控制 +function Svg({ children, ...p }: SVGProps) { + return ( + + ); +} + +type P = SVGProps; + +export const IconBooks = (p: P) => ( + + + + + +); + +export const IconSearch = (p: P) => ( + + + + +); + +export const IconRefresh = (p: P) => ( + + + + +); + +export const IconScan = (p: P) => ( + + + + +); + +export const IconX = (p: P) => ( + + + +); + +export const IconTrash = (p: P) => ( + + + +); + +export const IconLogout = (p: P) => ( + + + + +); + +export const IconArrowLeft = (p: P) => ( + + + +); + +export const IconBookOpen = (p: P) => ( + + + + +); + +export const IconUser = (p: P) => ( + + + + +); + +export const IconLock = (p: P) => ( + + + + +); + +export const IconCheck = (p: P) => ( + + + +); + +export const IconAlert = (p: P) => ( + + + + +); + +export const IconClock = (p: P) => ( + + + + +); + +export const IconFolder = (p: P) => ( + + + +); diff --git a/frontend/src/components/ui.ts b/frontend/src/components/ui.ts index e9a37aa..dbbad4f 100644 --- a/frontend/src/components/ui.ts +++ b/frontend/src/components/ui.ts @@ -1,7 +1,38 @@ +import type { Format } from "../api/types"; + export const btn = - "rounded-lg bg-stone-800 px-3 py-1.5 text-sm transition-colors hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-stone-600 disabled:cursor-not-allowed disabled:opacity-50"; + "inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = - "rounded-lg bg-amber-600 px-3 py-1.5 text-sm text-stone-950 transition-colors hover:bg-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"; + "inline-flex items-center gap-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-sm font-medium text-stone-950 shadow-md shadow-amber-500/20 transition-colors hover:bg-amber-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"; +export const btnGhost = + "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"; export const input = - "rounded-lg border border-stone-700 bg-stone-900 px-3 py-1.5 text-sm outline-none transition-colors placeholder:text-stone-500 focus:border-amber-500/70"; -export const card = "rounded-xl border border-stone-800 bg-stone-900"; + "rounded-lg bg-white/[0.04] px-3 py-1.5 text-sm ring-1 ring-white/10 outline-none transition placeholder:text-stone-500 focus:bg-white/[0.06] focus:ring-2 focus:ring-amber-500/60"; +export const card = "rounded-xl bg-stone-900/70 ring-1 ring-white/[0.07]"; + +// 库/筛选分段 pill +export const pill = + "rounded-full px-3.5 py-1.5 text-sm text-stone-400 ring-1 ring-transparent transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"; +export const pillActive = "bg-amber-500/15 text-amber-300 ring-amber-500/30 hover:bg-amber-500/20 hover:text-amber-200"; + +// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义 +export const FORMAT_BADGE: Record = { + cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25", + pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25", + epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25", + txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25", + md: "bg-teal-500/15 text-teal-300 ring-teal-500/25", +}; + +export function formatBadge(fmt: Format, extra = ""): string { + return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`; +} + +export function formatSize(bytes: number): string { + if (bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let n = bytes; + for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 277608f..d5fc3b1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7,7 +7,17 @@ body, } body { @apply bg-stone-950 text-stone-100 antialiased; + /* 顶部一抹极淡的琥珀氛围光,避免大面积纯黑的"朴素感" */ + background-image: radial-gradient(60rem 28rem at 50% -8rem, rgb(217 119 6 / 0.07), transparent 70%); + background-repeat: no-repeat; } + +/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ +@keyframes fx-rise { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} +.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } ::-webkit-scrollbar { width: 10px; height: 10px; @@ -57,6 +67,7 @@ body { @media (prefers-reduced-motion: reduce) { .rd-sheet { animation: none; } .rd-rise-anim { transition: none !important; } + .fx-rise { animation: none; } } /* markdown 正文(仅 .md-body 作用域) */ diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ed47ef0..5396009 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,9 +1,21 @@ -import { useState, type FormEvent } from "react"; +import { useState, type FormEvent, type ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; +import { IconBooks, IconLock, IconUser } from "../components/icons"; import { toast } from "../components/Toaster"; import { btnPrimary, card, input } from "../components/ui"; +function Field({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( + + ); +} + export default function Login() { const { login, user } = useAuth(); const loc = useLocation() as { state?: { from?: string } }; @@ -27,13 +39,39 @@ export default function Login() { return (
-
-

BookLib 登录

- setU(e.target.value)} - aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" /> - setP(e.target.value)} - aria-label="密码" placeholder="密码" autoComplete="current-password" /> -
diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index ef2635a..d4854c2 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -4,8 +4,9 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { api } from "../api/client"; import type { Format } from "../api/types"; import { useAuth } from "../auth/AuthContext"; +import { IconArrowLeft, IconTrash } from "../components/icons"; import { toast } from "../components/Toaster"; -import { btn } from "../components/ui"; +import { btn, btnGhost, formatBadge } from "../components/ui"; import type { ReaderProps } from "../lib/useProgress"; const READERS: Partial>>> = { @@ -68,17 +69,15 @@ export default function Reader() { (chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0") } > - - ← 书架 + + + 书架 {book?.title ?? "…"} {book && ( {book.library && {book.library}} - {book.format} + {book.format} {Math.round(book.percent * 100)}% )} @@ -89,6 +88,7 @@ export default function Reader() { if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate(); }} > + 删除 )} diff --git a/frontend/src/pages/Shelf.tsx b/frontend/src/pages/Shelf.tsx index 954a144..c28fd21 100644 --- a/frontend/src/pages/Shelf.tsx +++ b/frontend/src/pages/Shelf.tsx @@ -5,63 +5,87 @@ import { api } from "../api/client"; import type { Book } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { Cover } from "../components/Cover"; +import { IconAlert, IconBookOpen, IconClock, IconFolder, IconRefresh, IconScan, IconSearch, IconX } from "../components/icons"; import { TopBar } from "../components/TopBar"; import { toast } from "../components/Toaster"; -import { btn, btnPrimary, card, input } from "../components/ui"; +import { btn, card, formatBadge, formatSize, input, pill, pillActive } from "../components/ui"; import { groupByDir } from "../lib/group"; function BookCard({ book, + index, onOpen, onDelete, }: { book: Book; + index: number; onOpen: () => void; onDelete: () => void; }) { const { isAdmin } = useAuth(); + const pct = Math.round(book.percent * 100); return ( -
- - {book.state === "error" && ( - - 损坏 - - )} - {book.percent > 0 && ( -
-
-
- )} {isAdmin && ( )}
); } +const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9rem,1fr))]"; + +function Section({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ); +} + export default function Shelf() { const qc = useQueryClient(); const nav = useNavigate(); @@ -110,90 +134,146 @@ export default function Shelf() { const continueRows = (progQ.data ?? []).slice(0, 12); const libs = libsQ.data ?? []; + const searching = q.length > 0; return (
- +
+ + setQRaw(e.target.value)} + /> +
{isAdmin && lib > 0 && ( )} - setQRaw(e.target.value)} - /> + } /> -
- {libs.length === 0 && ( -

还没有书库。admin 请到「库管理」建库并扫描。

+
+ {libs.length === 0 && !libsQ.isPending && ( +

+ + 还没有书库。admin 请到「库管理」建库并扫描。 +

)} -
- - {libs.map((l) => ( - - ))} -
+ {libs.map((l) => ( + + ))} + + )} - {continueRows.length > 0 && ( -
-

继续阅读

-
- {continueRows.map((r) => { + {continueRows.length > 0 && !searching && ( +
}> +
    + {continueRows.map((r, i) => { const b = booksByKey.get(`${r.library_id}/${r.path}`); if (!b) return ( -
    -

    {r.title || "已删除的书"}

    -

    {Math.round(r.percent * 100)}%

    -
    +
  • +

    {r.title || "已删除的书"}

    +

    #{r.library_id}/{r.path} · 进度已保留

    +
  • ); + const pct = Math.round(r.percent * 100); return ( - nav(`/book/${b.id}`)} - onDelete={() => del.mutate(b.id)} - /> +
  • + +
  • ); })} -
-
+ + )} {booksQ.isError && ( -

- 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "} +

+ + 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"} -

+
)} + + {booksQ.isPending && !booksQ.isError && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ )} + {!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( -

此库暂无书目(放入文件后等待扫描或点「扫描此库」)。

+
+ +

{searching ? `没有匹配「${q}」的书` : "此库暂无书目"}

+

+ {searching ? "换个关键词试试。" : "放入文件后等待扫描,或点上方「扫描此库」。"} +

+
)} + {groups.map((g) => ( -
-

{g.dir || "根目录"}

-
- {g.books.map((b) => ( - nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> +
} + > +
+ {g.books.map((b, i) => ( + nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> ))}
-
+
))}
diff --git a/frontend/test/ui.test.ts b/frontend/test/ui.test.ts new file mode 100644 index 0000000..3c6dde2 --- /dev/null +++ b/frontend/test/ui.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { formatSize } from "../src/components/ui"; + +describe("formatSize", () => { + it("renders human sizes across units", () => { + expect(formatSize(0)).toBe(""); + expect(formatSize(512)).toBe("512 B"); + expect(formatSize(2048)).toBe("2.0 KB"); + expect(formatSize(15 * 1024 * 1024)).toBe("15 MB"); + expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB"); + }); +}); From a43b73dcef4bba131dd90919abff52b290a17625 Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 13:05:58 +0800 Subject: [PATCH 02/34] =?UTF-8?q?fix(cbz):=20attach=20resize=20observer=20?= =?UTF-8?q?via=20callback=20ref=20+=20drop=20vh=20from=20scroll-mode=20geo?= =?UTF-8?q?metry=20deps=20=E2=80=94=20fixes=20constant=20per-page=20gaps?= =?UTF-8?q?=20and=20refetch=20storm=20on=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 +++ frontend/src/readers/CbzReader.tsx | 18 +++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 6b6a5cc..31b5625 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -38,6 +38,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Fixed / 修复 +- 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/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index c79fb66..800445d 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -106,22 +106,26 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome const [mode, setMode] = useStoredMode(); const fitH = mode === "fit"; + // vh 只在适高模式参与几何;滚动模式里地址栏收展改 vh 不得重建(会丢全部实测页高→页间错位+重挂图请求风暴) const ph = useMemo( () => new PageHeights(count, fitH ? vh : width * ratio.current), - [count, width, vh, fitH], + [count, width, fitH ? vh : 0], ); - // 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准) - useEffect(() => { - const el = boxRef.current; + // 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点) + const roRef = useRef(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); - return () => ro.disconnect(); - }, []); + roRef.current = ro; + } // 首次拿到 count 后恢复进度页 useEffect(() => { @@ -222,7 +226,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome }} >
Date: Tue, 8 Sep 2026 13:08:46 +0800 Subject: [PATCH 03/34] feat(reader): remove admin delete button from reader header; shelf card hover remains the only delete entry --- docs/CHANGELOG_web.md | 5 +++++ frontend/src/components/icons.tsx | 6 ------ frontend/src/pages/Reader.tsx | 31 +++---------------------------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 31b5625..a2eac5c 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -36,6 +36,11 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab - 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 hover action, so a destructive tap can't happen one step away from the page-turn zone while reading. +- 阅读器顶栏右上角不再提供 admin「删除」按钮:删除入口仅保留在书架封面卡片的悬停操作中,避免阅读时误触翻页区旁的破坏性删除。 + ### Fixed / 修复 - 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. diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 8a0782c..a5a678c 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -55,12 +55,6 @@ export const IconX = (p: P) => ( ); -export const IconTrash = (p: P) => ( - - - -); - export const IconLogout = (p: P) => ( diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index d4854c2..2454315 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -1,11 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { api } from "../api/client"; import type { Format } from "../api/types"; -import { useAuth } from "../auth/AuthContext"; -import { IconArrowLeft, IconTrash } from "../components/icons"; -import { toast } from "../components/Toaster"; +import { IconArrowLeft } from "../components/icons"; import { btn, btnGhost, formatBadge } from "../components/ui"; import type { ReaderProps } from "../lib/useProgress"; @@ -20,9 +18,6 @@ const READERS: Partial setChromeOn((v) => !v), []); @@ -31,15 +26,6 @@ export default function Reader() { const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); - const del = useMutation({ - mutationFn: () => api.deleteBook(id), - onSuccess: () => { - toast("ok", "已删除"); - qc.invalidateQueries({ queryKey: ["books"] }); - nav("/"); - }, - onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), - }); const book = bookQ.data; const body = (() => { @@ -81,17 +67,6 @@ export default function Reader() { {Math.round(book.percent * 100)}% )} - {isAdmin && book && ( - - )}
}>{body} From a39ddf7b57c0c4961455376cf5046226ff6394d2 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 14:45:15 +0800 Subject: [PATCH 04/34] docs: add cbz chapter-scoped reading design spec --- ...8-cbz-chapter-continuous-reading-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md diff --git a/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md b/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md new file mode 100644 index 0000000..14c6d11 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md @@ -0,0 +1,73 @@ +# CBZ 章节化连续阅读(锁章 + 预读)设计 / CBZ chapter-scoped continuous reading design + +日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(会话内确认)。 + +## 目标 / Goal + +漫画(CBZ)阅读以「章」为单位:一章内无论多少图都视为一段连续的整章内容, +默认不能靠上下滑动越过章界进入下一章(只能点「下一章」),可开「连读」让滑动贯穿章节。 +去掉现有 连读/整页/适高 三档概念。锁章时预渲染(挂载+解码)其后 N 章(用户可配置,默认 2), +切章零白屏。 +CBZ reading is chapter-scoped: images within a chapter flow continuously; by default scrolling cannot cross a chapter boundary (only the 下一章 button can); an optional 连读 mode lets scrolling flow into following chapters. The three-mode cycle (连读/整页/适高) is removed. The next N chapters (user-configurable, default 2) are pre-rendered while locked so chapter switches are instant. + +## 决策记录 / Decisions + +- 章节内显示 = 按容器宽度(≤720px)连续滚动、无 snap、无适高一屏一页。用户选定(方案 A)。 +- 翻页手势/按键 = 章内翻一屏(`scrollBy ±vh`),不再按图跳页。用户选定。 +- 采用「按章节作用域渲染」(方案 A),否决:全书渲染+滚动钳制(手感生硬、浪费加载)、 + 一章一挂载(书签/进度/量高学习跨章迁移复杂、切章闪烁)。 +- 预读实现为隐藏层挂载解码(不增加可滚动高度),不违反锁章语义。用户要求「可预渲染下两章、可配置」。 +- 进度滑条/页码/书签/服务端进度均仍为全书页号,locator 形状 `{page}` 不变,无后端/API 改动。 +- 扁平包(chapters 为 null)= 整本一章:章按钮与连读开关隐藏,锁章/预读无意义,行为即纯连续滚动。 + +## 前端 / Frontend(全部改动在 `frontend/src`,纯前端) + +### 状态与持久化 + +- 删除 `PageMode`/`MODES`/`MODE_LABEL`/`useStoredMode` 及 `cbz-mode` 键;PageImg 去掉 `fitH` 分支; + 滚动盒去掉 `snap-y snap-proximity`。 +- `localStorage("cbz-continuous")`:连读开关,默认关(锁章)。 +- `localStorage("cbz-prefetch")`:预读章数 0–3,默认 2。工具栏步进器按钮循环 0→1→2→3→0。 + +### 区间几何 + +- 当前章 `ci` 由滚动位置(现 `cur` 所在页 → 章)推出,章起点 `chapters[ci].start`。 +- `PageHeights` 仍覆盖全书 `count` 页(复用现有量高/估高/`applyShift` 补偿逻辑)。 +- 挂载上限 `mountEnd`:锁章 = 第 `ci+1+prefetch` 章起点(越界取 count);连读 = count。 +- 滚动终点 `scrollEnd`:锁章 = `ph.offset(章末) + 章末卡片高`;连读 = 全书末尾。 +- 超出 `scrollEnd` 的预读页渲染进一个 `height:0; overflow:hidden` 的绝对定位隐藏层 + (子页仍按 `ph.offset(i)` 定位、`aria-hidden`):blob 经 `fetchObjectUrl` 缓存、`` 挂载即解码, + 但不参与滚动几何。切章后这些页落入可见区间即直接上屏。 +- 纯计算抽为 `lib/virt.ts` 导出 helper(如 `chapterRange(chapters, count, ci, continuous, prefetch)` + → `{mountStart, mountEnd, scrollEndPage}`),vitest 覆盖。 + +### 交互 + +- 点按左右侧区/←→/PageUp·Down:`scrollBy(±vh, smooth)`;锁章时滚动高度天然停在章末卡片。 +- 点中间仍唤出工具栏(不变)。 +- 章末卡片:锁章时本章末尾一屏「本章完 · 下一章 →」(最终章显示「全书完」),点卡片=切章。 +- 切章入口(上一章/下一章按钮、目录行、书签 seek、章末卡片、滑条拖入他章区间): + 设 `ci` 并 `jump(page)`;滑条跨章视为显式定位,不算「滑动越界」。 +- 恢复进度:由 `initialLocator.page` 反推所在章后定位。 +- 闲时「停滚 250ms 预取后 5 页」保留。 + +## 错误处理 / Errors + +- 无新增失败面:页数接口、鉴权取图、失败重试(「本页加载失败」)均沿用。 +- `cbz-prefetch` 值非法(手改 localStorage)→ 回退默认 2;`cbz-continuous` 非 `"1"` → 关。 + +## 测试 / Tests + +- `lib/virt.ts` helper 表驱动单测:锁/连读 × 预读 0–3 × 首章/末章/单章扁平包 × 边界页; + 断言 mountEnd/scrollEnd 不越过滚动锁、预读不超过 count。 +- 现有 `virt` 用例保持绿;`npm run check` 绿。 +- 真实浏览器点验:锁章滚不到下章、开连读可滑过、预读下切章无白屏、扁平包无章 UI。 + +## 文档 / Docs + +- `docs/CHANGELOG_web.md` 新增条目(英中各一行),不重复进 `docs/CHANGELOG.md`。 + +## 不做 / Non-goals + +- 后端章节结构改动、章内页号重映射、PDF/EPUB/TXT 阅读器改动、横翻 RTL 模式、 + 长按拖拽越章「窥视」下一章。 From 37ddff89a50f9d1cb6a37916786b9cc5b38bc1a2 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 14:51:03 +0800 Subject: [PATCH 05/34] =?UTF-8?q?docs(cbz):=20chapter-scoped=20reading=20i?= =?UTF-8?q?mplementation=20plan=20=E2=80=94=203=20tasks,=20virt=20helpers?= =?UTF-8?q?=20TDD=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-09-08-cbz-chapter-continuous-reading.md | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md 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..ea3c93c --- /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 ( +
+ 本页加载失败 + +
+ ); + if (!src) + return
; + return ( + { + 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(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(null); + const [width, setWidth] = useState(480); + const [vh, setVh] = useState(600); + const [top, setTop] = useState(0); + const [scrub, setScrub] = useState(null); // 页滑条拖拽中的临时值 + const [, bump] = useState(0); + const restored = useRef(false); + const scrollRaf = useRef(0); + const idle = useRef>(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(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(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) { + 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
页索引加载中…
; + if (countQ.isError || !book.page_url_fmt) + return ( +
+ 无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"} +
+ ); + + 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 ( +
{ + if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); + else return; + e.preventDefault(); + }} + > +
+
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { + const i = a + k; + return ( +
+ measure(i, h)} /> +
+ ); + })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ )} + {locked && w.mountEnd > w.end && ( + // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) + + )} +
+
+ {chrome.on ? ( +
+
+ { + 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="页码跳转" + /> + + {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}% + +
+
+ + {locked && ( + + )} + {bm.btn} + {chapters && ( + <> + + + {ci + 1}/{chapters.length} + + + + + )} +
+
+ ) : ( +
+ + {cur + 1}/{count} + + {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% +
+ )} + {toc && chapters && ( + <> + + ); +} +``` + +- [ ] **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》节,内容如下) + +```markdown +- 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** + +```bash +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 使用一致。 From 497dc1660442f7f321d91568ada7dbe9f91ee371 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 14:53:29 +0800 Subject: [PATCH 06/34] =?UTF-8?q?fix(deploy):=20COPY=20paths=20in=20web=20?= =?UTF-8?q?Dockerfiles=20still=20referenced=20old=20web/=20dir=20after=20w?= =?UTF-8?q?eb->frontend=20rename=20=E2=80=94=20dev=20image=20build=20faile?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/Dockerfile.dev | 2 +- frontend/Dockerfile.prod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index e2252f1..42184ef 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -1,6 +1,6 @@ FROM node:22 AS dev WORKDIR /app -COPY frontend/package.json web/package-lock.json ./ +COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci EXPOSE 5173 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index c4a0a93..db00097 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -1,6 +1,6 @@ FROM node:22-alpine AS build WORKDIR /src -COPY frontend/package.json web/package-lock.json ./ +COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build From bba82e552989e1983f2cf59f9535301bd15e0289 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 14:54:34 +0800 Subject: [PATCH 07/34] docs(plan): correct container names to podman compose underscores --- .../plans/2026-09-08-cbz-chapter-continuous-reading.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index ea3c93c..3ea8e7f 100644 --- a/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md +++ b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md @@ -13,7 +13,7 @@ Spec: `docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.m ## 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`。 +- 测试/构建在容器内跑:`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 均为全书页,不改后端。 @@ -73,7 +73,7 @@ it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefe - [ ] **Step 2: 跑测试确认失败** -Run: `docker exec -w /app booklib-web-1 npx vitest run test/virt.test.ts` +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` 末尾) @@ -112,7 +112,7 @@ export function chapterWin( - [ ] **Step 4: 跑测试确认通过(含既有 virt 用例回归)** -Run: `docker exec -w /app booklib-web-1 npx vitest run test/virt.test.ts` +Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts` Expected: 全部 PASS(既有 4 条 + 新增 4 条)。 - [ ] **Step 5: Commit** @@ -550,7 +550,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome - [ ] **Step 2: 静态检查 + 单测全绿** -Run: `docker exec -w /app booklib-web-1 npm run -s check` +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》节,内容如下) @@ -583,7 +583,7 @@ git commit -m "feat(cbz): chapter-scoped continuous reading — lock scroll at c - [ ] **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): <具体现象>"`。 +- [ ] **Step 7: 全量回归收尾**——`docker exec -w /app booklib_web_1 npm run -s check` 绿;有修复则 `git add -A && git commit -m "fix(cbz): <具体现象>"`。 --- From cd251b940c8d9fd5a1bd6fe3c1ae33e632f3f7c0 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 14:56:46 +0800 Subject: [PATCH 08/34] feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests --- frontend/src/lib/virt.ts | 30 ++++++++++++++++++++++++++++++ frontend/test/virt.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/virt.ts b/frontend/src/lib/virt.ts index 03a75e5..64a8962 100644 --- a/frontend/src/lib/virt.ts +++ b/frontend/src/lib/virt.ts @@ -76,3 +76,33 @@ export class PageHeights { return [a, b]; } } + +/** 页号 → 所在章下标;无章(扁平包或单组)恒 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, + }; +} diff --git a/frontend/test/virt.test.ts b/frontend/test/virt.test.ts index 59214bf..ac140c5 100644 --- a/frontend/test/virt.test.ts +++ b/frontend/test/virt.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { PageHeights } from "../src/lib/virt"; +import { chapterIndexAt, chapterWin, PageHeights } from "../src/lib/virt"; it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => { const ph = new PageHeights(10, 100); @@ -37,3 +37,35 @@ it("n=0 时一切归空", () => { expect(ph.range(0, 100, 2)).toEqual([0, 0]); expect(ph.set(0, 50)).toBe(0); }); + +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 }); +}); From f63b639f64b4472fbc6e5b6bd22dc78857d26831 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 15:01:28 +0800 Subject: [PATCH 09/34] =?UTF-8?q?feat(cbz):=20chapter-scoped=20continuous?= =?UTF-8?q?=20reading=20=E2=80=94=20lock=20scroll=20at=20chapter=20end,=20?= =?UTF-8?q?screen=20paging,=20prefetch=20next=20N=20chapters;=20drop=20?= =?UTF-8?q?=E8=BF=9E=E8=AF=BB/=E6=95=B4=E9=A1=B5/=E9=80=82=E9=AB=98=20mode?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 + frontend/src/readers/CbzReader.tsx | 227 +++++++++++++++++------------ 2 files changed, 140 insertions(+), 90 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index a2eac5c..713116d 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,6 +10,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- 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 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。 + - 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)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。 diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 800445d..15bc4d3 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -5,35 +5,30 @@ import { btn } from "../components/ui"; import { useBookmarks } from "../components/Bookmarks"; import { fetchObjectUrl } from "../lib/authImage"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; -import { PageHeights } from "../lib/virt"; +import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt"; const MAX_W = 720; const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅) -type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏) -const MODES: PageMode[] = ["scroll", "snap", "fit"]; -const MODE_LABEL: Record = { scroll: "连读", snap: "整页", fit: "适高" }; - -function useStoredMode() { - const [mode, setMode] = useState(() => { - const v = localStorage.getItem("cbz-mode"); - return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll"; +function useStoredBool(key: string, def: boolean) { + const [v, setV] = useState(() => { + const s = localStorage.getItem(key); + return s === null ? def : s === "1"; }); - useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]); - return [mode, setMode] as const; + useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]); + return [v, setV] as const; } -function PageImg({ - url, - width, - fitH, - onLoaded, -}: { - url: string; - width: number; - fitH: boolean; - onLoaded: (h: number) => void; -}) { +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); @@ -58,11 +53,7 @@ function PageImg({
); if (!src) - return ( -
- ); - if (fitH) - return ; + return
; return ( (null); const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent }); - const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(Number(l.page) || 0) }); + const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) }); const boxRef = useRef(null); const [width, setWidth] = useState(480); const [vh, setVh] = useState(600); @@ -103,14 +94,17 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop const shiftRaf = useRef(0); const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定) - const [mode, setMode] = useStoredMode(); - const fitH = mode === "fit"; + const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章 + const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3 + const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步 + const pending = useRef(null); // 跨章跳页:切窗后下一帧落位 - // vh 只在适高模式参与几何;滚动模式里地址栏收展改 vh 不得重建(会丢全部实测页高→页间错位+重挂图请求风暴) - const ph = useMemo( - () => new PageHeights(count, fitH ? vh : width * ratio.current), - [count, width, fitH ? vh : 0], + 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(null); @@ -127,14 +121,24 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome roRef.current = ro; } - // 首次拿到 count 后恢复进度页 + // 首次拿到 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) boxRef.current.scrollTop = ph.offset(p); + 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; @@ -146,6 +150,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome 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); @@ -177,26 +182,42 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome } 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) { + function scrollToPage(i: number, smooth: boolean) { const el = boxRef.current; if (!el) return; - el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" }); - chrome.show(); + el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" }); } - // 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区) + // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径) + 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) { 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) jump(cur - 1); - else if (x > 0.72) jump(cur + 1); + if (x < 0.28) turnScreen(-1); + else if (x > 0.72) turnScreen(1); else chrome.toggle(); } @@ -211,59 +232,76 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome ); const fmt: string = book.page_url_fmt; - const [a, b] = ph.range(top, vh, 2); + 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 (
{ - if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1); - else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1); + if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); else return; e.preventDefault(); }} > -
-
- {Array.from({ length: b - a }, (_, k) => { +
+
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { const i = a + k; return ( -
- { - if (fitH) return; // 适高模式定高一页一屏,无需量高 - 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); - }} - /> +
+ measure(i, h)} />
); })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ )} + {locked && w.mountEnd > w.end && ( + // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) + + )}
{chrome.on ? ( @@ -277,7 +315,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome onChange={(e) => { const v = Number(e.target.value); setScrub(v); - jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿 + goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿 }} onPointerUp={() => setScrub(null)} onPointerCancel={() => setScrub(null)} @@ -291,12 +329,21 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
+ {locked && ( + + )} {bm.btn} {chapters && ( <> @@ -306,13 +353,13 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {ci + 1}/{chapters.length} - @@ -343,7 +390,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome ref={i === ci ? activeRow : undefined} className={"rd-row" + (i === ci ? " rd-btn-on" : "")} onClick={() => { - jump(c.start); + goPage(c.start); setToc(false); }} > From 60c4ccf26f124de645f124a3403c810d06134e49 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 15:15:42 +0800 Subject: [PATCH 10/34] fix(cbz): window-relative scroll geometry locks head boundary; default prefetch 2 when key absent --- frontend/src/readers/CbzReader.tsx | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 15bc4d3..4f93455 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -21,7 +21,9 @@ function useStoredBool(key: string, def: boolean) { function useStoredPrefetch() { const [n, setN] = useState(() => { - const v = Number(localStorage.getItem("cbz-prefetch")); + const s = localStorage.getItem("cbz-prefetch"); + if (s === null) return 2; // 键缺失 ≠ 0:新用户默认 2 + const v = Number(s); return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2; }); useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]); @@ -105,6 +107,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome [chapters, count, ci, continuous, prefetch], ); const locked = !!chapters && chapters.length >= 2 && !continuous; + // 锁章下窗外前缀页不渲染 → scrollTop/页 top 一律窗口相对;读 ph(全局前缀和)前先加回 anchor + const anchor = locked ? ph.offset(w.start) : 0; // 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点) const roRef = useRef(null); @@ -136,7 +140,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome if (!el) return; const t = pending.current; pending.current = null; - el.scrollTop = ph.offset(t); + el.scrollTop = ph.offset(t) - anchor; }, [ci, ph]); // 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴 @@ -149,7 +153,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome const t = el.scrollTop; setTop(t); if (count) { - curRef.current = ph.pageAt(t + vh / 2); + curRef.current = ph.pageAt(t + anchor + vh / 2); if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮) saver.report({ page: curRef.current }, (curRef.current + 1) / count); } @@ -181,7 +185,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome }); } - const cur = ph.pageAt(top + vh / 2); + const cur = ph.pageAt(top + anchor + vh / 2); useEffect(() => { if (toc) activeRow.current?.scrollIntoView({ block: "center" }); @@ -190,7 +194,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome 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" }); + el.scrollTo({ + top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))) - anchor, + behavior: smooth ? "smooth" : "auto", + }); } // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径) @@ -232,14 +239,14 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome ); const fmt: string = book.page_url_fmt; - const [a0, b0] = ph.range(top, vh, 2); + const [a0, b0] = ph.range(top + anchor, vh, 2); // 视口位置换回全局空间再查页(锁章下 top 是窗口相对的) const a = Math.max(a0, w.start); const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层 - const contentH = locked ? ph.offset(w.end) + vh : ph.total(); + const contentH = locked ? ph.offset(w.end) - anchor + vh : ph.total(); function measure(i: number, h: number) { const el = boxRef.current; - const topY = el ? el.scrollTop : 0; + const topY = el ? el.scrollTop + anchor : 0; // 与 ph.offset 同处全局空间比较 const oldEst = ph.estHeight; const topOfI = ph.offset(i); const d = ph.set(i, h); @@ -247,7 +254,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome // 让未量高页的估高收敛到实测页高:同开本漫画量一页后 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)); + // n×Δest 是全局位移;锁章下 anchor 同步变了(前缀窗外页),那部分不影响窗口相对几何,须扣减 + const corr = n * (h - oldEst) - (ph.offset(w.start) - anchor); + if (el && corr) applyShift(corr); ratio.current = h / width; } bump((x) => x + 1); @@ -270,7 +279,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {Array.from({ length: Math.max(0, b - a) }, (_, k) => { const i = a + k; return ( -
+
measure(i, h)} />
); @@ -278,7 +287,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {locked && (
{w.end >= count ? ( — 全书完 — @@ -295,7 +304,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {Array.from({ length: w.mountEnd - w.end }, (_, k) => { const i = w.end + k; return ( -
+
measure(i, h)} />
); From e5dacc0a7706cd352943747b32cd69fb5a510df4 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 15:28:27 +0800 Subject: [PATCH 11/34] =?UTF-8?q?fix(cbz):=20clamp=20locked=20cur=20at=20c?= =?UTF-8?q?hapter=20end,=20hide=20=E8=BF=9E=E8=AF=BB=20on=20flat=20packs,?= =?UTF-8?q?=20silent=20restore,=20tighten=20prefetch=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/readers/CbzReader.tsx | 35 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 4f93455..78d318b 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -22,7 +22,7 @@ function useStoredBool(key: string, def: boolean) { function useStoredPrefetch() { const [n, setN] = useState(() => { const s = localStorage.getItem("cbz-prefetch"); - if (s === null) return 2; // 键缺失 ≠ 0:新用户默认 2 + if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2 const v = Number(s); return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2; }); @@ -130,7 +130,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome 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); + if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false); }, [count, initialLocator, ph]); // 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效 @@ -154,6 +154,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome setTop(t); if (count) { curRef.current = ph.pageAt(t + anchor + vh / 2); + if (locked) curRef.current = Math.min(curRef.current, w.end - 1); // 末屏中心落在章末卡片/他章 → 钉在本章末页 if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮) saver.report({ page: curRef.current }, (curRef.current + 1) / count); } @@ -185,7 +186,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome }); } - const cur = ph.pageAt(top + anchor + vh / 2); + const curRaw = ph.pageAt(top + anchor + vh / 2); + const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度) useEffect(() => { if (toc) activeRow.current?.scrollIntoView({ block: "center" }); @@ -200,10 +202,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome }); } - // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径) - function goPage(i: number, smooth = true) { + // 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片皆此一路径);恢复进度走 show=false 静默落位 + function goPage(i: number, smooth = true, show = true) { const t = Math.max(0, Math.min(count - 1, i)); - chrome.show(); + if (show) chrome.show(); if (locked && (t < w.start || t >= w.end)) { setCi(chapterIndexAt(chapters, t)); pending.current = t; @@ -253,9 +255,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动 // 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架 if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) { + const a0 = ph.offset(w.start); // 基线取当前实际 anchor:连续 measure 交错时闭包 anchor 可能过期,防前缀份额双扣 const n = ph.setEst(h, topY); - // n×Δest 是全局位移;锁章下 anchor 同步变了(前缀窗外页),那部分不影响窗口相对几何,须扣减 - const corr = n * (h - oldEst) - (ph.offset(w.start) - anchor); + // n×Δest 是全局位移;锁章下窗外前缀页的那部分不影响窗口相对几何,须扣减 + const corr = n * (h - oldEst) - (ph.offset(w.start) - a0); if (el && corr) applyShift(corr); ratio.current = h / width; } @@ -337,13 +340,15 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
- + {chapters && chapters.length >= 2 && ( + + )} {locked && ( )}
); } - if (!src) return
; + if (!src) return
; return ( {`《${book.title}》封面`} ); } diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index d5e29af..9678fad 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; -import { IconAlert, IconCheck } from "./icons"; +import { CheckCircle2, XCircle } from "lucide-react"; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; @@ -32,13 +32,11 @@ export function Toaster() {
- {t.kind === "ok" ? : } + {t.kind === "ok" ? : } {t.text}
))} diff --git a/frontend/src/components/theme.tsx b/frontend/src/components/theme.tsx new file mode 100644 index 0000000..343bbdd --- /dev/null +++ b/frontend/src/components/theme.tsx @@ -0,0 +1,47 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +export type Theme = "system" | "light" | "dark"; +const KEY = "ui.theme"; +const SYS = "(prefers-color-scheme: dark)"; + +function read(): Theme { + try { + const v = localStorage.getItem(KEY); + return v === "light" || v === "dark" || v === "system" ? v : "system"; + } catch { + return "system"; + } +} + +function apply(t: Theme) { + const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches); + document.documentElement.classList.toggle("dark", dark); +} + +const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, set] = useState(read); + useEffect(() => { + apply(theme); + try { + localStorage.setItem(KEY, theme); + } catch { + /* 隐私模式:仅本次会话生效 */ + } + if (theme !== "system") return; + const mq = matchMedia(SYS); + const on = () => apply("system"); + mq.addEventListener("change", on); + return () => mq.removeEventListener("change", on); + }, [theme]); + const setTheme = useCallback((t: Theme) => set(t), []); + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); + return {children}; +} + +export function useTheme() { + const v = useContext(Ctx); + if (!v) throw new Error("useTheme outside ThemeProvider"); + return v; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d5fc3b1..6d2e7c7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,74 @@ @import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(0.985 0.001 80); + --foreground: oklch(0.22 0.01 70); + --card: oklch(0.995 0 0); + --card-foreground: oklch(0.22 0.01 70); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.01 70); + --primary: oklch(0.25 0.012 70); + --primary-foreground: oklch(0.98 0.002 80); + --secondary: oklch(0.95 0.003 80); + --secondary-foreground: oklch(0.32 0.01 70); + --muted: oklch(0.95 0.003 80); + --muted-foreground: oklch(0.55 0.008 70); + --accent: oklch(0.68 0.16 46); + --accent-foreground: oklch(0.985 0.002 80); + --destructive: oklch(0.58 0.21 27); + --border: oklch(0.91 0.004 80); + --input: oklch(0.91 0.004 80); + --ring: oklch(0.62 0.01 70); +} +.dark { + --background: oklch(0.19 0.006 80); + --foreground: oklch(0.95 0.003 80); + --card: oklch(0.23 0.006 80); + --card-foreground: oklch(0.95 0.003 80); + --popover: oklch(0.23 0.006 80); + --popover-foreground: oklch(0.95 0.003 80); + --primary: oklch(0.95 0.003 80); + --primary-foreground: oklch(0.22 0.01 70); + --secondary: oklch(0.28 0.006 80); + --secondary-foreground: oklch(0.95 0.003 80); + --muted: oklch(0.28 0.006 80); + --muted-foreground: oklch(0.68 0.006 80); + --accent: oklch(0.7 0.16 46); + --accent-foreground: oklch(0.19 0.006 80); + --destructive: oklch(0.65 0.19 25); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.6 0.01 70); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} html, body, @@ -6,10 +76,23 @@ body, height: 100%; } body { - @apply bg-stone-950 text-stone-100 antialiased; - /* 顶部一抹极淡的琥珀氛围光,避免大面积纯黑的"朴素感" */ - background-image: radial-gradient(60rem 28rem at 50% -8rem, rgb(217 119 6 / 0.07), transparent 70%); - background-repeat: no-repeat; + @apply bg-background text-foreground antialiased; + color-scheme: light; +} +.dark body { + color-scheme: dark; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + @apply rounded-full border-2 border-transparent bg-clip-padding; + background-color: color-mix(in oklab, var(--foreground) 22%, transparent); +} +::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--foreground) 34%, transparent); } /* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ @@ -18,16 +101,6 @@ body { to { opacity: 1; transform: none; } } .fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-thumb { - @apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70; -} -::-webkit-scrollbar-thumb:hover { - @apply bg-stone-600; -} /* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */ [data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5ba74d3..ebf7d01 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ name: "BookLib 个人书库", short_name: "BookLib", description: "个人书库/漫画库在线阅读", - theme_color: "#0c0a09", - background_color: "#0c0a09", + theme_color: "#fafaf9", + background_color: "#fafaf9", display: "standalone", icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }], }, From 913f3286bbd5002b4a071ecaa878e9c2084d4bd6 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 17:29:07 +0800 Subject: [PATCH 17/34] feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests --- frontend/src/components/ui.ts | 11 ++--------- frontend/src/lib/format.ts | 8 ++++++++ frontend/src/lib/shelf.ts | 19 +++++++++++++++++++ frontend/test/format.test.ts | 18 ++++++++++++++++++ frontend/test/shelf.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/shelf.ts create mode 100644 frontend/test/format.test.ts create mode 100644 frontend/test/shelf.test.ts diff --git a/frontend/src/components/ui.ts b/frontend/src/components/ui.ts index dbbad4f..29a98f7 100644 --- a/frontend/src/components/ui.ts +++ b/frontend/src/components/ui.ts @@ -1,5 +1,7 @@ import type { Format } from "../api/types"; +export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器清除 + export const btn = "inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = @@ -27,12 +29,3 @@ export const FORMAT_BADGE: Record = { export function formatBadge(fmt: Format, extra = ""): string { return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`; } - -export function formatSize(bytes: number): string { - if (bytes <= 0) return ""; - const units = ["B", "KB", "MB", "GB"]; - let i = 0; - let n = bytes; - for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; - return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; -} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..5ac9eff --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,8 @@ +export function formatSize(bytes: number): string { + if (bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let n = bytes; + for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} diff --git a/frontend/src/lib/shelf.ts b/frontend/src/lib/shelf.ts new file mode 100644 index 0000000..c39c525 --- /dev/null +++ b/frontend/src/lib/shelf.ts @@ -0,0 +1,19 @@ +import type { Book } from "../api/types"; +import { groupByDir } from "./group"; + +export type SortKey = "added" | "title" | "progress"; +export const SORT_LABEL: Record = { added: "最近入库", title: "标题", progress: "阅读进度" }; +export type GroupMode = "flat" | "dir"; + +export function sortBooks(books: Book[], key: SortKey): Book[] { + const a = [...books]; + if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh")); + if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); + return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); +} + +/** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */ +export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] { + if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }]; + return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books })); +} diff --git a/frontend/test/format.test.ts b/frontend/test/format.test.ts new file mode 100644 index 0000000..5c8dc82 --- /dev/null +++ b/frontend/test/format.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { formatSize } from "../src/lib/format"; + +describe("formatSize", () => { + it("renders human sizes across units", () => { + expect(formatSize(0)).toBe(""); + expect(formatSize(512)).toBe("512 B"); + expect(formatSize(2048)).toBe("2.0 KB"); + expect(formatSize(15 * 1024 * 1024)).toBe("15 MB"); + expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB"); + }); + + it("returns empty for non-positive and keeps one decimal below 10", () => { + expect(formatSize(-1)).toBe(""); + expect(formatSize(1024)).toBe("1.0 KB"); + expect(formatSize(10 * 1024)).toBe("10 KB"); + }); +}); diff --git a/frontend/test/shelf.test.ts b/frontend/test/shelf.test.ts new file mode 100644 index 0000000..5cd36c5 --- /dev/null +++ b/frontend/test/shelf.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import type { Book } from "../src/api/types"; +import { sections, sortBooks } from "../src/lib/shelf"; + +const b = (over: Partial): Book => + ({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book; + +it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => { + const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" }); + const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" }); + expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]); + expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]); + expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]); + const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })]; + expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]); +}); + +it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => { + const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })]; + // 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1 + expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]); + const g = sections(books, "title", "dir"); + expect(g.map((s) => s.label)).toEqual(["", "x"]); + expect(g[1].books.map((x) => x.title)).toEqual(["A", "t"]); // x 组内按 title 排序 +}); From 7957e5cee5faaba1502ca5b90945b08c5c2417a5 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 17:35:14 +0800 Subject: [PATCH 18/34] =?UTF-8?q?feat(ui):=20AppShell=20layout=20route=20?= =?UTF-8?q?=E2=80=94=20desktop=20rail,=20tablet=20icon=20rail,=20mobile=20?= =?UTF-8?q?tabs,=20theme=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 30 ++---- frontend/src/components/app-shell.tsx | 131 +++++++++++++++++++++++ frontend/src/components/theme-toggle.tsx | 32 ++++++ 3 files changed, 170 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/app-shell.tsx create mode 100644 frontend/src/components/theme-toggle.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc4ff58..f319bcc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext"; +import { AppShell } from "./components/app-shell"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Toaster } from "./components/Toaster"; import { ThemeProvider } from "./components/theme"; @@ -17,13 +18,16 @@ export default function App() { } /> - + } - /> + > + } /> + } /> + } /> + } /> - - - - - - } - /> - - - - - - } - /> } /> diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx new file mode 100644 index 0000000..32a78d7 --- /dev/null +++ b/frontend/src/components/app-shell.tsx @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query"; +import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { api } from "@/api/client"; +import { useAuth } from "@/auth/AuthContext"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; +import { ThemeToggle } from "./theme-toggle"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +function Brand({ compact }: { compact?: boolean }) { + return ( + + + + + {!compact && BookLib} + + ); +} + +function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { + return ( + + cn( + "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground", + isActive && "bg-secondary font-medium text-foreground", + ) + } + > + {icon} + {label} + + ); +} + +export function AppShell() { + const { user, isAdmin, logout } = useAuth(); + const loc = useLocation(); + const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); + const libs = libsQ.data ?? []; + + const account = ( + + + + + + {user?.username}{isAdmin ? " · admin" : ""} + + + + 退出登录 + + + + + ); + + return ( +
+ {/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */} + + +
+ {/* 移动端顶栏 */} +
+ +
+ + {account} +
+
+
+ +
+ {/* 移动端底 Tab */} + +
+
+ ); +} + +function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { + return ( + cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}> + {icon} + {label} + + ); +} diff --git a/frontend/src/components/theme-toggle.tsx b/frontend/src/components/theme-toggle.tsx new file mode 100644 index 0000000..53a8dd7 --- /dev/null +++ b/frontend/src/components/theme-toggle.tsx @@ -0,0 +1,32 @@ +import { Check, Monitor, Moon, Sun } from "lucide-react"; +import { useTheme, type Theme } from "./theme"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; + +const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [ + { v: "light", label: "浅色", icon: Sun }, + { v: "dark", label: "深色", icon: Moon }, + { v: "system", label: "跟随系统", icon: Monitor }, +]; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + return ( + + + + + + {ITEMS.map(({ v, label, icon: Icon }) => ( + setTheme(v)}> + + {label} + {theme === v && } + + ))} + + + ); +} From 52e95a48d85e52ff7bbdc01a3eb374a8439994f9 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 17:41:29 +0800 Subject: [PATCH 19/34] fix(ui): gate app sidebar to md+ and use onSelect for logout (keyboard path) --- frontend/src/components/app-shell.tsx | 8 +++----- frontend/src/components/theme-toggle.tsx | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 32a78d7..fb683a8 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -57,10 +57,8 @@ export function AppShell() { {user?.username}{isAdmin ? " · admin" : ""} - - - 退出登录 - + + 退出登录 @@ -69,7 +67,7 @@ export function AppShell() { return (
{/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */} -
@@ -340,49 +342,79 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
- {chapters && chapters.length >= 2 && ( - - )} - {locked && ( - - )} {chapters && ( <> {ci + 1}/{chapters.length} - )} + {chapters && chapters.length >= 2 && ( + + + 更多 + + + + setContinuous(!!v)} + > + 连读(滑动跨章) + + + + + {locked && ( +
+
+ 预读:锁章末尾时提前挂载其后 N 章 · 当前 {prefetch} +
+ setPrefetch(Number(e.target.value))} + /> +
+ )} +
+
+
+ )}
) : ( diff --git a/frontend/src/readers/EpubReader.tsx b/frontend/src/readers/EpubReader.tsx index 3561a0f..39ee137 100644 --- a/frontend/src/readers/EpubReader.tsx +++ b/frontend/src/readers/EpubReader.tsx @@ -1,3 +1,4 @@ +import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { apiRaw } from "../api/client"; import { BookmarksTab } from "../components/Bookmarks"; @@ -100,14 +101,19 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea {!ready && !err &&
EPUB 加载中…
}
- - -
{nav && ( diff --git a/frontend/src/readers/PdfReader.tsx b/frontend/src/readers/PdfReader.tsx index 847d21c..53511e1 100644 --- a/frontend/src/readers/PdfReader.tsx +++ b/frontend/src/readers/PdfReader.tsx @@ -1,3 +1,4 @@ +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, PanelLeft } from "lucide-react"; import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import * as pdfjs from "pdfjs-dist"; import { apiRaw } from "../api/client"; @@ -139,23 +140,28 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
- - {page + 1}/{num} - - -
{nav && ( diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx index d1fb1e3..eb80855 100644 --- a/frontend/src/readers/TextReader.tsx +++ b/frontend/src/readers/TextReader.tsx @@ -1,5 +1,6 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; +import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { apiRaw } from "../api/client"; import { BookmarksTab } from "../components/Bookmarks"; @@ -187,12 +188,12 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) { {chrome.on && ( )} @@ -310,23 +311,31 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {chrome.on && ( {chapters.length > 1 && ( <> {ci + 1}/{chapters.length} - - )} From 472e58bec9b7b98aad5fe74a7ba580d33a4d0f2a Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 21:30:26 +0800 Subject: [PATCH 32/34] =?UTF-8?q?fix(web):=20unify=20=E6=9B=B4=E5=A4=9A=20?= =?UTF-8?q?menu=20text=20size,=20add=200-3=20tick=20anchors=20under=20pref?= =?UTF-8?q?etch=20slider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/readers/CbzReader.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index fd7537a..af69618 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -396,9 +396,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {locked && (
-
- 预读:锁章末尾时提前挂载其后 N 章 · 当前 {prefetch} -
+
预读:锁章末尾时提前挂载其后 N 章
setPrefetch(Number(e.target.value))} /> +
)} From 3178c2802fda145cccc30a0ea851c4f4f568ddc8 Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 21:31:20 +0800 Subject: [PATCH 33/34] refactor(web): extract prefetch slider into reusable RdSlider component --- frontend/src/components/rd-slider.tsx | 37 +++++++++++++++++++++++++++ frontend/src/readers/CbzReader.tsx | 28 +++++++------------- 2 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/rd-slider.tsx diff --git a/frontend/src/components/rd-slider.tsx b/frontend/src/components/rd-slider.tsx new file mode 100644 index 0000000..e2f732e --- /dev/null +++ b/frontend/src/components/rd-slider.tsx @@ -0,0 +1,37 @@ +/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */ +export function RdSlider({ + label, + ariaLabel, + min, + max, + value, + onChange, +}: { + label: string; + ariaLabel: string; + min: number; + max: number; + value: number; + onChange: (v: number) => void; +}) { + return ( +
+
{label}
+ onChange(Number(e.target.value))} + /> + +
+ ); +} diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index af69618..2d531f6 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { api, formatPageUrl } from "../api/client"; import { btn } from "../components/ui"; import { BookmarksTab } from "../components/Bookmarks"; +import { RdSlider } from "../components/rd-slider"; import { ReaderNav, type NavTab } from "../components/reader-nav"; import { fetchObjectUrl } from "../lib/authImage"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; @@ -395,25 +396,14 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome {locked && ( -
-
预读:锁章末尾时提前挂载其后 N 章
- setPrefetch(Number(e.target.value))} - /> - -
+ )} From fd227c1f006d84cc49bd2928378670fe06828bab Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 21:45:03 +0800 Subject: [PATCH 34/34] =?UTF-8?q?feat(web):=20table-style=20=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86,=20admin=20entries=20under=20avatar=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86,=20shelf-view=20upload=20b?= =?UTF-8?q?utton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 + frontend/src/components/app-shell.tsx | 24 ++++-- frontend/src/components/upload-button.tsx | 59 ++++++++++++++ frontend/src/pages/Shelf.tsx | 12 ++- frontend/src/pages/admin/Libraries.tsx | 98 +++++++++-------------- 5 files changed, 124 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/upload-button.tsx diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 351b7f6..6d7f398 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -31,6 +31,9 @@ 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 工具条的「更多」菜单。 diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 9352366..8e7b4f5 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -58,6 +58,22 @@ export function AppShell() { {user?.username}{isAdmin ? " · admin" : ""} + {isAdmin && ( + <> + + 系统管理 + + + 库管理 + + + + + 用户管理 + + + + )} 退出登录 @@ -73,12 +89,6 @@ export function AppShell() { {libs.length > 0 && (
@@ -112,8 +122,6 @@ export function AppShell() { {/* 移动端底 Tab */}
diff --git a/frontend/src/components/upload-button.tsx b/frontend/src/components/upload-button.tsx new file mode 100644 index 0000000..350a792 --- /dev/null +++ b/frontend/src/components/upload-button.tsx @@ -0,0 +1,59 @@ +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useRef, useState, type ComponentProps } from "react"; +import { Upload } from "lucide-react"; +import { api } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { toast } from "./Toaster"; + +export async function uploadToLib(qc: QueryClient, libId: number, list: FileList | File[]): Promise { + const files = Array.from(list); // FileList 活引用,先快照 + if (!files.length) return false; + try { + for (const f of files) await api.uploadBook(libId, f); + toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`); + void qc.invalidateQueries({ queryKey: ["books"] }); + setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000); + return true; + } catch (e) { + toast("err", e instanceof Error ? e.message : "上传失败"); + return false; + } +} + +/** 上传按钮(隐藏 file input),白名单:cbz/zip/pdf/epub/txt/md. */ +export function UploadButton({ + libId, + variant, + size = "sm", + label = "上传", +}: { + libId: number; + variant?: ComponentProps["variant"]; + size?: ComponentProps["size"]; + label?: string; +}) { + const qc = useQueryClient(); + const ref = useRef(null); + const [busy, setBusy] = useState(false); + return ( + <> + { + const files = Array.from(e.target.files ?? []); + e.target.value = ""; + setBusy(true); + await uploadToLib(qc, libId, files); + setBusy(false); + }} + /> + + + ); +} diff --git a/frontend/src/pages/Shelf.tsx b/frontend/src/pages/Shelf.tsx index 5d8d027..9931404 100644 --- a/frontend/src/pages/Shelf.tsx +++ b/frontend/src/pages/Shelf.tsx @@ -7,6 +7,7 @@ import type { Book } from "@/api/types"; import { useAuth } from "@/auth/AuthContext"; import { Cover } from "@/components/Cover"; import { toast } from "@/components/Toaster"; +import { UploadButton } from "@/components/upload-button"; import { formatBadge } from "@/components/ui"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -177,10 +178,13 @@ export default function Shelf() { ))}
{isAdmin && lib > LIB0 && ( - + <> + + + )} - {libsQ.isPending && ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
+ {libsQ.isPending ? ( + + ) : libs.length === 0 ? ( +

还没有库,用上方表单创建。

+ ) : ( + + + + ID + 库名 + 服务端路径 + + + + + {libs.map((l) => ( + e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}> + {l.id} + {l.name} + {l.root_path} + +
+ + +
+
+
+ ))} +
+
)} -
- {libs.map((l) => ( - e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}> - - - {l.name} - #{l.id} - -

{l.root_path}

-
- - - { - fileRefs.current[l.id] = el; - }} - type="file" - multiple - accept=".cbz,.zip,.pdf,.epub,.txt,.md" - className="hidden" - onChange={(e) => { - void onFiles(l.id, e.target.files); - e.target.value = ""; - }} - /> - - 白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片 - -
- ))} - {libs.length === 0 && !libsQ.isPending &&

还没有库,用上方表单创建。

} -
+

上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行

); }