+ {x.id}
{x.username}
{x.role}
- {x.created_at.slice(0, 19).replace("T", " ")}
+ {x.created_at.slice(0, 19).replace("T", " ")}
void }) {
+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";
+ });
+ useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]);
+ return [mode, setMode] as const;
+}
+
+function PageImg({
+ url,
+ width,
+ fitH,
+ onLoaded,
+}: {
+ url: string;
+ width: number;
+ fitH: boolean;
+ onLoaded: (h: number) => void;
+}) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
@@ -26,7 +49,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
}, [url, nonce]);
if (failed)
return (
-
+
本页加载失败
setNonce((n) => n + 1)}>
重试
@@ -34,7 +57,11 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
);
if (!src)
- return
;
+ return (
+
+ );
+ if (fitH)
+ return
;
return (
api.pageCount(book.pages_url ?? ""),
@@ -62,10 +89,22 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
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 [mode, setMode] = useStoredMode();
+ const fitH = mode === "fit";
- const ph = useMemo(() => new PageHeights(count, width * EST_RATIO), [count, width]);
+ const ph = useMemo(
+ () => new PageHeights(count, fitH ? vh : width * ratio.current),
+ [count, width, vh, fitH],
+ );
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
useEffect(() => {
@@ -87,49 +126,125 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
}, [count, initialLocator, 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);
+ 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);
+
+ function jump(i: number, smooth = true) {
const el = boxRef.current;
if (!el) return;
- const t = el.scrollTop;
- setTop(t);
- if (count) {
- const cur = ph.pageAt(t + vh / 2);
- saver.report({ page: cur }, (cur + 1) / count);
- for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
- fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
- }
+ el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
+ chrome.show();
+ }
+
+ // 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/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);
+ else chrome.toggle();
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
- return 页索引加载中…
;
+ 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 [a, b] = ph.range(top, vh, 2);
- const cur = ph.pageAt(top + vh / 2);
return (
-
-
-
+
{
+ if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1);
+ else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1);
+ else return;
+ e.preventDefault();
+ }}
+ >
+
+
{Array.from({ length: 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 < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
+ 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);
}}
/>
@@ -138,9 +253,47 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
})}
-
- {cur + 1}/{count}
-
+ {chrome.on ? (
+
+
+ {
+ const v = Number(e.target.value);
+ setScrub(v);
+ jump(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)}%
+
+
+
+ setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length])}
+ aria-label="切换阅读模式"
+ >
+ {MODE_LABEL[mode]}
+
+
+
+ ) : (
+
+
+ {cur + 1}/{count}
+
+ {Math.round(((cur + 1) / Math.max(1, count)) * 100)}%
+
+ )}
);
}
diff --git a/frontend/src/readers/EpubReader.tsx b/frontend/src/readers/EpubReader.tsx
index e92179d..178ed0d 100644
--- a/frontend/src/readers/EpubReader.tsx
+++ b/frontend/src/readers/EpubReader.tsx
@@ -91,11 +91,11 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
return (
-
+
- {!ready && !err &&
EPUB 加载中…
}
+ {!ready && !err &&
EPUB 加载中…
}
-
+
rendRef.current?.prev()}>
上一页
diff --git a/frontend/src/readers/PdfReader.tsx b/frontend/src/readers/PdfReader.tsx
index 238f065..62bc376 100644
--- a/frontend/src/readers/PdfReader.tsx
+++ b/frontend/src/readers/PdfReader.tsx
@@ -123,28 +123,33 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
);
- if (num === 0) return
PDF 加载中…
;
+ if (num === 0) return
PDF 加载中…
;
return (
-
-
-
+
+
+
-
-
setPage(0)}>
- ⏮
+
+ setPage(0)} aria-label="第一页">
+ 首页
setPage((p) => Math.max(0, p - 1))}>
- 上一页
+ ← 上一页
-
+
{page + 1}/{num}
= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
- 下一页
+ 下一页 →
- = num - 1} onClick={() => setPage(num - 1)}>
- ⏭
+ = num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
+ 末页
diff --git a/frontend/src/readers/TextReader.tsx b/frontend/src/readers/TextReader.tsx
index 85506ba..4e382f1 100644
--- a/frontend/src/readers/TextReader.tsx
+++ b/frontend/src/readers/TextReader.tsx
@@ -1,12 +1,23 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { apiRaw } from "../api/client";
-import { btn, input } from "../components/ui";
-import { chapterText, splitChapters } from "../lib/chapters";
+import { btn } from "../components/ui";
+import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
+import {
+ FONT_SIZES,
+ RD_THEMES,
+ RD_THEME_LABEL,
+ useReaderPrefs,
+ type RdTheme,
+} from "../lib/readerPrefs";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
+type Prefs = ReturnType
;
+
+const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
+
function useFileText(url: string) {
const [text, setText] = useState(null);
const [err, setErr] = useState("");
@@ -31,7 +42,7 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
{onRetry &&
{text}
}
- {!onRetry &&
{text}
}
+ {!onRetry &&
{text}
}
{onRetry && (
重试
@@ -42,14 +53,89 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
);
}
+/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
+function ReaderSheet({
+ pr,
+ frac,
+ onSeek,
+ children,
+}: {
+ pr: Prefs;
+ frac: number;
+ onSeek: (f: number) => void;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ onSeek(Number(e.target.value) / 1000)}
+ className="rd-range"
+ aria-label="阅读进度"
+ />
+
+ {Math.round(frac * 100)}%
+
+
+
+ {children}
+
+
+ {RD_THEMES.map((t) => (
+ pr.setTheme(t)}
+ className="h-5 w-5 rounded-full transition-transform hover:scale-110"
+ style={{
+ background: SWATCH[t],
+ boxShadow:
+ pr.prefs.theme === t
+ ? "0 0 0 2px var(--rd-link)"
+ : "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
+ }}
+ />
+ ))}
+
+
pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
+ A−
+
+
pr.bump(1)}
+ disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
+ aria-label="加大字号"
+ >
+ A+
+
+
+
+
+ );
+}
+
+/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
+function bodyTogglesChrome(e: MouseEvent, toggle: () => void) {
+ if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
+ if (window.getSelection()?.toString()) return;
+ toggle();
+}
+
export default function TextReader(props: ReaderProps) {
return props.book.format === "md" ? : ;
}
-function MdView({ book, initialLocator }: ReaderProps) {
+function MdView({ book, initialLocator, chrome }: ReaderProps) {
const boxRef = useRef(null);
const saver = useProgressSaver(book.id);
const restored = useRef(false);
+ const pr = useReaderPrefs();
+ const [frac, setFrac] = useState(0);
const { text, err, retry } = useFileText(book.file_url ?? "");
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
@@ -68,27 +154,50 @@ function MdView({ book, initialLocator }: ReaderProps) {
const el = boxRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
- const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
- saver.report({ scrollFraction: frac }, frac);
+ const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
+ setFrac(f);
+ saver.report({ scrollFraction: f }, f);
+ }
+
+ function seek(f: number) {
+ const el = boxRef.current;
+ if (!el) return;
+ el.scrollTop = f * (el.scrollHeight - el.clientHeight);
}
if (err) return ;
if (text == null) return ;
return (
-
-
+
+
bodyTogglesChrome(e, chrome.toggle)}
+ className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
+ >
+
+
+ {chrome.on &&
}
);
}
// 整本塞一个
会冻死浏览器:按章渲染,一章一个滚动块。
-function TxtView({ book, initialLocator }: ReaderProps) {
+function TxtView({ book, initialLocator, chrome }: ReaderProps) {
const boxRef = useRef(null);
const saver = useProgressSaver(book.id);
const inited = useRef(false);
const restoreFrac = useRef(null);
const targetCi = useRef(0);
+ const activeRow = useRef(null);
const [ci, setCi] = useState(0);
+ const [frac, setFrac] = useState(0);
+ const [toc, setToc] = useState(false);
+ const pr = useReaderPrefs();
const { text, err, retry } = useFileText(book.file_url ?? "");
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -99,18 +208,15 @@ function TxtView({ book, initialLocator }: ReaderProps) {
const f = Number(initialLocator?.scrollFraction);
const hasF = Number.isFinite(f) && f > 0 && f <= 1;
const rawCh = Number(initialLocator?.ch);
- let idx = 0;
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
- idx = Math.floor(rawCh);
+ targetCi.current = Math.floor(rawCh);
+ setCi(Math.floor(rawCh));
if (hasF) restoreFrac.current = f;
} else if (hasF) {
- const p = f * chapters.length; // 旧版全局分数 → 章节近似定位
- idx = Math.min(chapters.length - 1, Math.floor(p));
- restoreFrac.current = p - idx;
- }
- if (idx > 0 || restoreFrac.current != null) {
+ const { idx, intra } = seekChapter(chapters.length, f); // 旧版全局分数 → 章节近似定位
targetCi.current = idx;
setCi(idx);
+ if (intra > 0) restoreFrac.current = intra;
}
}, [chapters, initialLocator]);
@@ -123,73 +229,106 @@ function TxtView({ book, initialLocator }: ReaderProps) {
} else el.scrollTop = 0;
}, [ci, chapters]);
- function goChapter(i: number) {
+ useEffect(() => {
+ if (toc) activeRow.current?.scrollIntoView({ block: "center" });
+ }, [toc]);
+
+ function goChapter(i: number, intra = 0) {
if (!chapters) return;
- restoreFrac.current = null;
+ restoreFrac.current = intra > 0 ? intra : null;
+ targetCi.current = i;
setCi(i);
- saver.report({ ch: i, scrollFraction: 0 }, i / chapters.length);
+ setFrac(intra);
+ saver.report({ ch: i, scrollFraction: intra }, (i + intra) / chapters.length);
}
function onScroll() {
const el = boxRef.current;
if (!el || !chapters) return;
const max = el.scrollHeight - el.clientHeight;
- const frac = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
- saver.report({ ch: ci, scrollFraction: frac }, (ci + frac) / chapters.length);
+ const f = max > 0 ? Math.min(1, Math.max(0, el.scrollTop / max)) : 0;
+ setFrac(f);
+ saver.report({ ch: ci, scrollFraction: f }, (ci + f) / chapters.length);
+ }
+
+ function seek(f: number) {
+ if (!chapters) return;
+ const { idx, intra } = seekChapter(chapters.length, f);
+ goChapter(idx, intra);
}
if (err) return ;
if (text == null || !chapters) return ;
+ const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
+
return (
-
- {chapters.length > 1 && (
-
- goChapter(Number(e.target.value))}>
- {(() => {
- const groups: { vol: string | undefined; items: number[] }[] = [];
- chapters.forEach((c, i) => {
- const last = groups[groups.length - 1];
- if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
- else last.items.push(i);
- });
- const opt = (i: number) => (
-
- {i + 1}. {chapters[i].title}
-
- );
- return groups.length === 1 && !groups[0].vol
- ? chapters.map((_, i) => opt(i))
- : groups.map((g, gi) =>
- g.vol ? (
-
- {g.items.map(opt)}
-
- ) : (
- g.items.map(opt)
- ),
- );
- })()}
-
-
- {ci + 1}/{chapters.length}
-
-
- )}
-
-
+
+
bodyTogglesChrome(e, chrome.toggle)}
+ className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
+ >
+
{chapterText(text, chapters, ci)}
- {chapters.length > 1 && (
-
- goChapter(ci - 1)}>
- 上一章
-
- goChapter(ci + 1)}>
- 下一章
-
-
+ {chrome.on && (
+
+ {chapters.length > 1 && (
+ <>
+ setToc((v) => !v)}
+ >
+ 目录
+
+
+ {ci + 1}/{chapters.length}
+
+ goChapter(ci - 1)}>
+ ← 上一章
+
+ goChapter(ci + 1)}>
+ 下一章 →
+
+ >
+ )}
+
+ )}
+ {/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
+ {toc && chapters.length > 1 && (
+ <>
+
setToc(false)} />
+
+
+ {chapters.map((c, i) => (
+
+ {
+ goChapter(i);
+ setToc(false);
+ }}
+ >
+ {i + 1}. {c.title}
+
+
+ ))}
+
+
+ >
)}
);
diff --git a/frontend/test/chapters.test.ts b/frontend/test/chapters.test.ts
index d4ef59b..56cf15c 100644
--- a/frontend/test/chapters.test.ts
+++ b/frontend/test/chapters.test.ts
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
-import { chapterText, splitChapters } from "../src/lib/chapters";
+import { chapterText, seekChapter, splitChapters } from "../src/lib/chapters";
const pad = (n: number) => "字".repeat(n);
@@ -39,20 +39,28 @@ it("短文无标记 → 单节,chapterText 即全文", () => {
expect(chapterText(t, chs, 0)).toBe(t);
});
-it("卷不是章节边界:只按章切,卷题作分组标签并并入所属章开头", () => {
+it("卷不感知:只按章切,卷题行留在正文里", () => {
const t =
"第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) +
"\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300);
const chs = splitChapters(t);
expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]);
- expect(chs.map((c) => c.vol)).toEqual(["第一卷 天云世界", "第一卷 天云世界", "第二卷 仙界"]);
- expect(chs[2].start).toBe(t.indexOf("第二卷 仙界")); // 卷题行从本章开头渲染,而非上一章结尾
- expect(chapterText(t, chs, 2)).toMatch(/^第二卷 仙界/);
+ expect(chs[0].start).toBe(t.indexOf("第一章"));
+ expect(chs[2].start).toBe(t.indexOf("第三章"));
+ expect(chapterText(t, chs, 1)).toContain("第二卷 仙界"); // 卷题在第二章结尾,不进第三章开头
});
-it("只有卷没有章的书:卷即章节", () => {
+it("seekChapter:全局进度分数 → (章序号, 章内分数),端点与越界安全", () => {
+ expect(seekChapter(69, 0)).toEqual({ idx: 0, intra: 0 });
+ expect(seekChapter(69, 0.5)).toEqual({ idx: 34, intra: 0.5 }); // 69*0.5=34.5
+ expect(seekChapter(69, 1).idx).toBe(68); // 顶到 0.9999 防越界
+ expect(seekChapter(69, 1).intra).toBeGreaterThan(0.99);
+ expect(seekChapter(69, -0.2)).toEqual({ idx: 0, intra: 0 });
+ expect(seekChapter(1, 0.3)).toEqual({ idx: 0, intra: 0.3 });
+});
+
+it("只有卷没有章的书:按伪章节切", () => {
const t = "第一卷 起\n" + pad(600) + "\n第二卷 承\n" + pad(600) + "\n第三卷 转\n" + pad(600);
const chs = splitChapters(t);
- expect(chs.map((c) => c.title)).toEqual(["第一卷 起", "第二卷 承", "第三卷 转"]);
- expect(chs.every((c) => c.vol === undefined)).toBe(true);
+ expect(chs).toEqual([{ title: "第 1 节", start: 0 }]);
});
diff --git a/frontend/test/virt.test.ts b/frontend/test/virt.test.ts
index aafceeb..59214bf 100644
--- a/frontend/test/virt.test.ts
+++ b/frontend/test/virt.test.ts
@@ -23,6 +23,14 @@ it("set 修正高度并平移后续 offset;返回增量;幂等", () => {
expect(ph.set(2, 150)).toBe(0);
});
+it("setEst:重估默认页高,返回视口上方「未量高」页数,已量高页保留", () => {
+ const ph = new PageHeights(5, 100); // cum 顶部: 0,100,200,300,400
+ ph.set(0, 160); // 页0 实测 160 → cum 顶部: 0,160,260,360,460
+ expect(ph.setEst(140, 260)).toBe(1); // 顶部<260 且未量高:页1(160)。页0 已量高排除,页2(260)不<260
+ expect(ph.total()).toBe(160 + 4 * 140); // 页0 实测保留,其余按新估高
+ expect(ph.setEst(140, 9999)).toBe(0); // 同值幂等:est 不变直接 0
+});
+
it("n=0 时一切归空", () => {
const ph = new PageHeights(0, 100);
expect(ph.total()).toBe(0);
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index fca6d67..c413fe4 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -14,8 +14,8 @@ export default defineConfig({
name: "BookLib 个人书库",
short_name: "BookLib",
description: "个人书库/漫画库在线阅读",
- theme_color: "#09090b",
- background_color: "#09090b",
+ theme_color: "#0c0a09",
+ background_color: "#0c0a09",
display: "standalone",
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
},