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 }));
}