From 97f6d2d3b6b2e01573c33e2fb98ffd150a0255cc Mon Sep 17 00:00:00 2001 From: XingfenD Date: Mon, 7 Sep 2026 15:47:11 +0800 Subject: [PATCH] feat(web): throttled progress saver with keepalive flush, reader dispatch shell --- web/src/App.tsx | 7 +-- web/src/lib/progress.ts | 57 ++++++++++++++++++++++ web/src/lib/useProgress.ts | 38 +++++++++++++++ web/src/pages/Reader.tsx | 96 ++++++++++++++++++++++++++++++++++++++ web/test/progress.test.ts | 42 +++++++++++++++++ 5 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/progress.ts create mode 100644 web/src/lib/useProgress.ts create mode 100644 web/src/pages/Reader.tsx create mode 100644 web/test/progress.test.ts 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 消费,定时器不再补发 +});