import type { ApiError, ApiResponse } from "@umt/contracts"; export type { ApiResponse }; // ─── API Key 存储 ─────────────────────────────────────────────── const TOKEN_KEY = "umt-api-key"; export function getToken(): string { if (typeof localStorage === "undefined") return ""; return localStorage.getItem(TOKEN_KEY) || ""; } export function setToken(token: string) { localStorage.setItem(TOKEN_KEY, token); } export function clearAuth() { localStorage.removeItem(TOKEN_KEY); } // ─── 请求参数 ──────────────────────────────────────────────────── export type FetcherArg = { /** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */ pathParams?: Record; params?: Record; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; body?: B; headers?: Record; signal?: AbortSignal; }; export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const fail = (code: ApiError["code"], message: string): ApiResponse => ({ ok: false, error: { code, message }, }); /** * 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。 * queryFn: () => apiFetch("/api/v1/health") * mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body }) */ export async function apiFetch(path: string, arg: FetcherArg = {}): Promise> { const { method = "GET", headers = {}, body, params, pathParams, signal } = arg; let url = `${API_BASE.replace(/\/+$/, "")}${path}`; if (pathParams) { for (const [key, value] of Object.entries(pathParams)) { url = url.replace(`:${key}`, encodeURIComponent(String(value))); } } if (params) { const qs = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) qs.append(key, String(value)); } const s = qs.toString(); if (s) url += (url.includes("?") ? "&" : "?") + s; } const reqHeaders: Record = { ...headers }; const token = getToken(); if (token) reqHeaders.Authorization = `Bearer ${token}`; let reqBody: BodyInit | undefined; if (body instanceof FormData) { reqBody = body; } else if (body !== undefined && body !== null) { reqHeaders["Content-Type"] ??= "application/json"; reqBody = JSON.stringify(body); } let res: Response; try { res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal }); } catch (err) { if ((err as Error).name === "AbortError") throw err; return fail("upstream_unavailable", "无法连接服务"); } if (res.status === 401 && typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("unauthorized")); } try { return (await res.json()) as ApiResponse; } catch { return fail("internal", `响应不是 JSON(HTTP ${res.status})`); } } /** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */ export async function apiData(path: string, arg?: FetcherArg): Promise { const r = await apiFetch(path, arg); if (!r.ok) throw Object.assign(new Error(r.error.message), { code: r.error.code }); return r.data; } /** TanStack Query mutationFn 包装 */ export function mutateFetcher(path: string, base?: Omit, "body">) { return (body: B) => apiFetch(path, { method: "POST", ...base, body }); } export const TTL_SECOND = 1_000; export const TTL_MINUTE = TTL_SECOND * 60; export const TTL_HOUR = TTL_MINUTE * 60;