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
+153
View File
@@ -0,0 +1,153 @@
import type { Book, Library, Me, ProgressRow, User } from "./types";
export const TOKEN_KEY = "booklib.token";
export const LOGOUT_EVENT = "booklib:logout";
// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效。
// 探测方法而非 typeof:node 22+ 的 experimental webstorage 会暴露无方法的 localStorage 桩。
const mem = new Map<string, string>();
const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
}
export function setToken(t: string | null): void {
if (hasLS) {
if (t) localStorage.setItem(TOKEN_KEY, t);
else localStorage.removeItem(TOKEN_KEY);
} else if (t) {
mem.set(TOKEN_KEY, t);
} else {
mem.delete(TOKEN_KEY);
}
}
export class HttpError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.code = code;
}
}
export async function toHttpError(res: Response): Promise<HttpError> {
let code = "http_" + res.status;
let message = res.statusText;
try {
const j = await res.json();
if (j?.error?.code) code = j.error.code;
if (j?.error?.message) message = j.error.message;
} catch {
/* 非 JSON 错误体:用状态码兜底 */
}
return new HttpError(res.status, code, message);
}
function authHeaders(): Record<string, string> {
const h: Record<string, string> = {};
const tok = getToken();
if (tok) h.Authorization = "Bearer " + tok;
return h;
}
function handle401(res: Response): void {
if (res.status !== 401) return;
setToken(null);
if (typeof window !== "undefined") window.dispatchEvent(new Event(LOGOUT_EVENT));
}
function full(path: string): string {
return path.startsWith("/api") ? path : "/api" + path;
}
export interface ReqOpts {
method?: string;
body?: unknown;
form?: FormData;
keepalive?: boolean;
signal?: AbortSignal;
}
export async function apiFetch<T>(path: string, opts: ReqOpts = {}): Promise<T> {
const headers = authHeaders();
let body: BodyInit | undefined;
if (opts.form) {
body = opts.form;
} else if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
const res = await fetch(full(path), {
method: opts.method ?? "GET",
headers,
body,
keepalive: opts.keepalive,
signal: opts.signal,
});
handle401(res);
if (!res.ok) throw await toHttpError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// 二进制/原文通道:封面、CBZ 页、PDF/EPUB 字节。同 401 语义,不解析 JSON
export async function apiRaw(
path: string,
opts: { signal?: AbortSignal; keepalive?: boolean } = {},
): Promise<Response> {
const res = await fetch(full(path), {
headers: authHeaders(),
signal: opts.signal,
keepalive: opts.keepalive,
});
handle401(res);
if (!res.ok) throw await toHttpError(res);
return res;
}
export function formatPageUrl(fmt: string, n: number): string {
return fmt.replace("%d", String(n));
}
export const api = {
login: (username: string, password: string) =>
apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }),
me: () => apiFetch<Me>("/auth/me"),
listUsers: () => apiFetch<User[]>("/users"),
createUser: (username: string, password: string, role: string) =>
apiFetch<User>("/users", { method: "POST", body: { username, password, role } }),
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
listLibraries: () => apiFetch<Library[]>("/libraries"),
createLibrary: (name: string, root_path: string) =>
apiFetch<Library>("/libraries", { method: "POST", body: { name, root_path } }),
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
uploadBook: (id: number, file: File) => {
const fd = new FormData();
fd.append("file", file);
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, {
method: "POST",
form: fd,
});
},
listBooks: (params: { library?: number; q?: string } = {}) => {
const sp = new URLSearchParams();
if (params.library) sp.set("library", String(params.library));
if (params.q) sp.set("q", params.q);
const qs = sp.toString();
return apiFetch<Book[]>(`/books${qs ? "?" + qs : ""}`);
},
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
};