diff --git a/web/src/App.tsx b/web/src/App.tsx
index 3620311..d6d8846 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -3,12 +3,9 @@ import { AuthProvider, RequireAuth } from "./auth/AuthContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import Login from "./pages/Login";
+import Reader from "./pages/Reader";
import Shelf from "./pages/Shelf";
-function Placeholder({ name }: { name: string }) {
- return
{name}:待后续任务落地
;
-}
-
export default function App() {
return (
@@ -27,7 +24,7 @@ export default function App() {
path="/book/:id"
element={
-
+
}
/>
diff --git a/web/src/lib/progress.ts b/web/src/lib/progress.ts
new file mode 100644
index 0000000..05f006a
--- /dev/null
+++ b/web/src/lib/progress.ts
@@ -0,0 +1,57 @@
+export type ProgressSend = (
+ locator: Record,
+ percent: number,
+ keepalive: boolean,
+) => Promise;
+
+export interface ProgressSaver {
+ report: (locator: Record, percent: number) => void;
+ flush: () => void;
+}
+
+// spec §8:PUT 节流 5s(leading-edge + 尾部合并);关页兜底走 keepalive fetch
+//(spec 原文 sendBeacon,但 beacon 无法带 Authorization,后端 Bearer-only,语义等价替换)
+export function createProgressSaver(send: ProgressSend, intervalMs = 5000): ProgressSaver {
+ let lastKey = "";
+ let pending: { loc: Record; pct: number } | null = null;
+ let timer: ReturnType | null = null;
+ let lastAt = 0;
+
+ function doSend(keepalive: boolean): void {
+ if (!pending) return;
+ const { loc, pct } = pending;
+ pending = null;
+ const key = JSON.stringify([loc, pct]);
+ if (key === lastKey) return;
+ lastKey = key;
+ send(loc, pct, keepalive).catch(() => {
+ if (lastKey === key) lastKey = ""; // 失败允许下次重发同值
+ });
+ }
+
+ return {
+ report(locator, percent) {
+ pending = { loc: locator, pct: percent };
+ if (timer) return; // 已有尾部定时器:只更新挂起值
+ const t = Date.now();
+ if (lastAt === 0 || t - lastAt >= intervalMs) {
+ lastAt = t;
+ doSend(false);
+ } else {
+ timer = setTimeout(() => {
+ timer = null;
+ lastAt = Date.now();
+ doSend(false);
+ }, intervalMs - (t - lastAt));
+ }
+ },
+ flush() {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ lastAt = Date.now();
+ doSend(true);
+ },
+ };
+}
diff --git a/web/src/lib/useProgress.ts b/web/src/lib/useProgress.ts
new file mode 100644
index 0000000..2821b79
--- /dev/null
+++ b/web/src/lib/useProgress.ts
@@ -0,0 +1,38 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo } from "react";
+import { api } from "../api/client";
+import type { Book } from "../api/types";
+import { createProgressSaver, type ProgressSaver } from "./progress";
+
+export interface ReaderProps {
+ book: Book;
+ initialLocator?: Record;
+}
+
+export function useProgressSaver(bookId: number): ProgressSaver {
+ const qc = useQueryClient();
+ const saver = useMemo(
+ () =>
+ createProgressSaver(async (loc, pct, ka) => {
+ await api.putProgress(bookId, loc, pct, ka);
+ if (!ka) qc.invalidateQueries({ queryKey: ["book", bookId] }); // spec §8:只 invalidate 单本
+ }),
+ [bookId, qc],
+ );
+
+ useEffect(() => {
+ const onPageHide = () => saver.flush();
+ const onVis = () => {
+ if (document.visibilityState === "hidden") saver.flush();
+ };
+ globalThis.addEventListener?.("pagehide", onPageHide);
+ document?.addEventListener?.("visibilitychange", onVis);
+ return () => {
+ saver.flush();
+ globalThis.removeEventListener?.("pagehide", onPageHide);
+ document?.removeEventListener?.("visibilitychange", onVis);
+ };
+ }, [saver]);
+
+ return saver;
+}
diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx
new file mode 100644
index 0000000..8c6f3cf
--- /dev/null
+++ b/web/src/pages/Reader.tsx
@@ -0,0 +1,96 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Suspense, type ComponentType, type LazyExoticComponent } from "react";
+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 { toast } from "../components/Toaster";
+import { btn } from "../components/ui";
+import type { ReaderProps } from "../lib/useProgress";
+
+// reader 任务逐个往里加:Task 7 cbz → Task 8 txt/md → Task 9 pdf → Task 10 epub
+const READERS: Partial>>> = {};
+
+export default function Reader() {
+ const { id: idStr } = useParams();
+ const id = Number(idStr);
+ const qc = useQueryClient();
+ const nav = useNavigate();
+ const { isAdmin } = useAuth();
+
+ 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 = (() => {
+ if (!Number.isFinite(id)) return ;
+ if (bookQ.isPending) return ;
+ if (bookQ.isError)
+ return (
+ bookQ.refetch()}
+ />
+ );
+ if (!book) return ;
+ if (book.state === "error") return ;
+ if (progQ.isPending) return ;
+ const R = READERS[book.format];
+ if (!R) return ;
+ const row = (progQ.data ?? []).find((r) => r.library_id === book.library_id && r.path === book.path);
+ return ;
+ })();
+
+ return (
+
+
+
+ ← 书架
+
+ {book?.title ?? "…"}
+
+ {book?.library ? book.library + " · " : ""}
+ {book?.format}
+ {book ? ` · ${Math.round(book.percent * 100)}%` : ""}
+
+ {isAdmin && book && (
+
+ )}
+
+
+ }>{body}
+
+
+ );
+}
+
+function Msg({ text, retry }: { text: string; retry?: () => void }) {
+ return (
+
+
+
{text}
+ {retry && (
+
+ )}
+
+
+ );
+}
diff --git a/web/test/progress.test.ts b/web/test/progress.test.ts
new file mode 100644
index 0000000..b3cb62b
--- /dev/null
+++ b/web/test/progress.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { createProgressSaver } from "../src/lib/progress";
+
+type Sent = [Record, number, boolean];
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+it("leading 立即发;间隔内多次 report 合并成一次 trailing", () => {
+ const sent: Sent[] = [];
+ const s = createProgressSaver((l, p, ka) => {
+ sent.push([l, p, ka]);
+ return Promise.resolve();
+ });
+ s.report({ page: 1 }, 0.1);
+ expect(sent.length).toBe(1);
+ s.report({ page: 2 }, 0.2);
+ s.report({ page: 3 }, 0.3);
+ expect(sent.length).toBe(1);
+ vi.advanceTimersByTime(4999);
+ expect(sent.length).toBe(1);
+ vi.advanceTimersByTime(1);
+ expect(sent.length).toBe(2);
+ expect(sent[1]).toEqual([{ page: 3 }, 0.3, false]);
+});
+
+it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", () => {
+ const sent: Sent[] = [];
+ const s = createProgressSaver((l, p, ka) => {
+ sent.push([l, p, ka]);
+ return Promise.resolve();
+ });
+ s.report({ page: 0 }, 0);
+ s.report({ page: 5 }, 0.5);
+ s.flush();
+ expect(sent.length).toBe(2);
+ expect(sent[1]).toEqual([{ page: 5 }, 0.5, true]);
+ s.flush();
+ expect(sent.length).toBe(2);
+ vi.advanceTimersByTime(10_000);
+ expect(sent.length).toBe(2); // 挂起已被 flush 消费,定时器不再补发
+});