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:
2026-09-07 21:24:33 +08:00
parent 95317a0108
commit 961de429f9
45 changed files with 7 additions and 3 deletions
+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 }));
}
+57
View File
@@ -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);
},
};
}
+5
View File
@@ -0,0 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 10_000 } },
});
+38
View File
@@ -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;
}
+63
View File
@@ -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];
}
}