fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type ProgressSend = (
|
||||
locator: Record<string, unknown>,
|
||||
percent: number,
|
||||
keepalive: boolean,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export interface ProgressSaver {
|
||||
report: (locator: Record<string, unknown>, 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<string, unknown>; pct: number } | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正,
|
||||
// 前缀和 + 二分定位。纯逻辑,无 DOM,vitest 全量覆盖。
|
||||
export class PageHeights {
|
||||
private actual: (number | undefined)[];
|
||||
private cum: number[]; // cum[i] = 第 i 页顶部偏移;cum[n] = total
|
||||
|
||||
constructor(
|
||||
private n: number,
|
||||
private est: number,
|
||||
) {
|
||||
this.actual = new Array(n).fill(undefined);
|
||||
this.cum = new Array(n + 1);
|
||||
this.rebuild(0);
|
||||
}
|
||||
|
||||
private rebuild(from: number): void {
|
||||
if (from === 0) this.cum[0] = 0;
|
||||
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
|
||||
}
|
||||
|
||||
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
|
||||
set(i: number, h: number): number {
|
||||
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
|
||||
const old = this.actual[i];
|
||||
if (old === h) return 0;
|
||||
this.actual[i] = h;
|
||||
this.rebuild(i);
|
||||
return h - (old ?? this.est);
|
||||
}
|
||||
|
||||
offset(i: number): number {
|
||||
if (this.n === 0) return 0;
|
||||
return this.cum[Math.max(0, Math.min(i, this.n))];
|
||||
}
|
||||
|
||||
total(): number {
|
||||
return this.cum[this.n];
|
||||
}
|
||||
|
||||
/** 覆盖 y 的那一页。 */
|
||||
pageAt(y: number): number {
|
||||
if (this.n === 0) return 0;
|
||||
let lo = 0;
|
||||
let hi = this.n - 1;
|
||||
let ans = 0;
|
||||
const v = Math.max(0, y);
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.cum[mid] <= v) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else hi = mid - 1;
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
|
||||
range(scrollTop: number, viewport: number, overscan: number): [number, number] {
|
||||
if (this.n === 0) return [0, 0];
|
||||
const a = Math.max(0, this.pageAt(scrollTop) - overscan);
|
||||
const b = Math.min(this.n, this.pageAt(scrollTop + viewport) + overscan);
|
||||
return [a, b];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user