feat(web): authed blob image pipeline with LRU, cover card, dir grouping

This commit is contained in:
2026-09-07 15:36:19 +08:00
parent b231b62720
commit 63b41f333e
5 changed files with 145 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage";
import { btn } from "./ui";
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 });
const [nonce, setNonce] = useState(0);
const key = (url ?? "") + "#" + nonce;
useEffect(() => {
if (!url) return;
let dead = false;
const k = url + "#" + nonce;
setState({ key: k, src: "", failed: false });
fetchObjectUrl(url)
.then((src) => !dead && setState({ key: k, src, failed: false }))
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
return () => {
dead = true;
};
}, [url, nonce]);
const same = state.key === key;
return { src: same ? state.src : "", failed: same && state.failed, retry: () => setNonce((n) => n + 1) };
}
export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (failed)
return (
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
重试
</button>
</div>
);
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
}
+30
View File
@@ -0,0 +1,30 @@
import { apiRaw } from "../api/client";
const MAX = 600;
const cache = new Map<string, Promise<string>>(); // 插入序 ≈ LRU(命中时重插)
// 带鉴权取图 → objectURL。后端 Bearer-only,<img>/<iframe> 直挂 /api 必 401,
// 故图片一律走此通道;URL 含内容 hash 不可变,会话内可安全长驻缓存。
export async function fetchObjectUrl(url: string, signal?: AbortSignal): Promise<string> {
const hit = cache.get(url);
if (hit) {
cache.delete(url);
cache.set(url, hit);
return hit;
}
const p = (async () => {
const res = await apiRaw(url, { signal });
return URL.createObjectURL(await res.blob());
})();
p.catch(() => cache.delete(url)); // 失败出缓存,下次调用重试
cache.set(url, p);
while (cache.size > MAX) {
const oldest = cache.keys().next().value as string;
cache.delete(oldest); // 不 revoke:可能仍被在屏 <img> 引用;≤600 条会话内 blob 可接受
}
return p;
}
export function __resetImageCache(): void {
cache.clear();
}
+23
View File
@@ -0,0 +1,23 @@
import type { Book } from "../api/types";
export interface Group {
dir: string; // path 去掉文件名即分组键(spec §4);根目录为 ""
books: Book[];
}
export function groupByDir(books: Book[]): Group[] {
const m = new Map<string, Book[]>();
for (const bk of books) {
const i = bk.path.lastIndexOf("/");
const dir = i < 0 ? "" : bk.path.slice(0, i);
let arr = m.get(dir);
if (!arr) {
arr = [];
m.set(dir, arr);
}
arr.push(bk);
}
return [...m.entries()]
.sort((a, b) => (a[0] === "" ? -1 : b[0] === "" ? 1 : a[0].localeCompare(b[0])))
.map(([dir, bs]) => ({ dir, books: bs }));
}
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { setToken } from "../src/api/client";
import { __resetImageCache, fetchObjectUrl } from "../src/lib/authImage";
beforeEach(() => {
__resetImageCache();
setToken("T");
(URL as any).createObjectURL = vi.fn(() => "blob:gen");
});
afterEach(() => vi.unstubAllGlobals());
it("blob → objectURL;同 URL 只发一次请求", async () => {
const f = vi.fn(async (_url: any, init: any) => {
expect(init.headers.Authorization).toBe("Bearer T");
return new Response(new Blob(["img"]), { status: 200 });
});
vi.stubGlobal("fetch", f);
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(await fetchObjectUrl("/api/books/1/cover?v=h")).toBe("blob:gen");
expect(f).toHaveBeenCalledTimes(1);
});
it("失败不入缓存,重试重新请求", async () => {
let n = 0;
vi.stubGlobal("fetch", async () => {
n++;
return n === 1 ? new Response("x", { status: 500 }) : new Response(new Blob(["img"]), { status: 200 });
});
await expect(fetchObjectUrl("/api/books/2/pages/0?v=h")).rejects.toThrow();
expect(await fetchObjectUrl("/api/books/2/pages/0?v=h")).toBe("blob:gen");
expect(n).toBe(2);
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from "vitest";
import { groupByDir } from "../src/lib/group";
import type { Book } from "../src/api/types";
let seq = 1;
const mk = (path: string): Book => ({
id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0,
state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover",
});
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
const gs = groupByDir([mk("b.cbz"), mk("z/1.cbz"), mk("a/2.cbz"), mk("a/sub/3.cbz")]);
expect(gs.map((g) => g.dir)).toEqual(["", "a", "a/sub", "z"]);
expect(gs[0].books.map((x) => x.path)).toEqual(["b.cbz"]);
expect(gs[1].books.map((x) => x.path)).toEqual(["a/2.cbz"]);
});
it("空输入出空数组", () => {
expect(groupByDir([])).toEqual([]);
});