import type { Book, Bookmark, 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(); 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 { 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 { const h: Record = {}; 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(path: string, opts: ReqOpts = {}): Promise { 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 { 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)); } const SINGLE_MAX = 16 << 20; const CHUNK_SIZE = 8 << 20; const PART_RETRY = 3; async function uploadChunked(id: number, file: File): Promise<{ accepted: boolean; path: string }> { const { uploadId } = await apiFetch<{ uploadId: string }>(`/libraries/${id}/upload/init`, { method: "POST", body: { name: file.name, size: file.size, chunkSize: CHUNK_SIZE }, }); const { received } = await apiFetch<{ received: number[] }>(`/uploads/${uploadId}`); const have = new Set(received); const n = Math.ceil(file.size / CHUNK_SIZE); for (let i = 0; i < n; i++) { if (have.has(i)) continue; for (let tries = 0; ; tries++) { const res = await fetch(full(`/uploads/${uploadId}/parts/${i}`), { method: "PUT", headers: { ...authHeaders(), "Content-Type": "application/octet-stream" }, body: file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size)), }); if (res.ok) break; if (tries >= PART_RETRY) throw await toHttpError(res); } } return apiFetch<{ accepted: boolean; path: string }>(`/uploads/${uploadId}/complete`, { method: "POST" }); } export const api = { login: (username: string, password: string) => apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }), me: () => apiFetch("/auth/me"), listUsers: () => apiFetch("/users"), createUser: (username: string, password: string, role: string) => apiFetch("/users", { method: "POST", body: { username, password, role } }), deleteUser: (id: number) => apiFetch(`/users/${id}`, { method: "DELETE" }), listLibraries: () => apiFetch("/libraries"), createLibrary: (name: string) => apiFetch("/libraries", { method: "POST", body: { name } }), scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }), // ≤16MB 单 POST;更大走分片协议(init→parts→complete),失败分片原地重试,已传分片跳过 uploadBook: (id: number, file: File): Promise<{ accepted: boolean; path: string }> => file.size <= SINGLE_MAX ? (() => { const fd = new FormData(); fd.append("file", file); return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, { method: "POST", form: fd }); })() : uploadChunked(id, file), 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(`/books${qs ? "?" + qs : ""}`); }, getBook: (id: number) => apiFetch(`/books/${id}`), deleteBook: (id: number) => apiFetch(`/books/${id}`, { method: "DELETE" }), pageCount: (pagesUrl: string) => apiFetch<{ count: number; chapters?: { title: string; start: number }[] | null }>(pagesUrl), putProgress: (id: number, locator: Record, percent: number, keepalive = false) => apiFetch(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }), listBookmarks: (id: number) => apiFetch(`/books/${id}/bookmarks`), createBookmark: (id: number, locator: Record, percent: number, note: string) => apiFetch(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }), patchBookmark: (id: number, note: string) => apiFetch(`/bookmarks/${id}`, { method: "PATCH", body: { note } }), deleteBookmark: (id: number) => apiFetch(`/bookmarks/${id}`, { method: "DELETE" }), listProgress: () => apiFetch("/progress"), };