import type { ApiError, ApiErrorCode, ApiResponse } from "@umt/contracts"; export type { ApiResponse }; // 控制台鉴权:cookie 会话优先(credentials: "include",写方法带 CSRF 头); // 只有 localStorage 里存了 API Key 时才额外附加 Bearer(脚本/调试用,Bearer 调用服务端不校验 CSRF)。 // 任意请求收到 401 → 跳登录页(带 next 回跳),/auth/* 探测类请求除外。 // ─── API Key 存储(可选) ───────────────────────────────────────── const TOKEN_KEY = "umt-api-key"; const CSRF_COOKIE = "umt_csrf"; const CSRF_HEADER = "x-csrf-token"; export const LOGIN_PATH = "/login"; export function getToken(): string { try { if (typeof localStorage === "undefined") return ""; return localStorage.getItem(TOKEN_KEY) || ""; } catch { return ""; } } export function setToken(token: string) { localStorage.setItem(TOKEN_KEY, token); } export function clearAuth() { localStorage.removeItem(TOKEN_KEY); } function readCookie(name: string): string { if (typeof document === "undefined") return ""; for (const part of document.cookie.split(";")) { const [k, ...v] = part.trim().split("="); if (k === name) return decodeURIComponent(v.join("=")); } return ""; } // ─── 请求参数 ──────────────────────────────────────────────────── export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type QueryParams = Record; export type FetcherArg = { /** 路径变量,`:id` 与 `{id}` 两种写法都替换,如 { id: "..." } */ pathParams?: Record; params?: QueryParams; method?: HttpMethod; body?: B; headers?: Record; signal?: AbortSignal; /** 401 时不跳登录页(登录页自己的探测/提交用) */ noAuthRedirect?: boolean; }; export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; /** 业务接口前缀,与 contracts 的 API_PREFIX 一致 */ export const API_V1 = "/api/v1"; const fail = (code: ApiError["code"], message: string): ApiResponse => ({ ok: false, error: { code, message }, }); /** 拼完整 URL(不含 origin):路径变量 + query */ export function buildUrl(path: string, arg: Pick = {}): string { let url = `${API_BASE.replace(/\/+$/, "")}${path.startsWith("/api/") ? path : `${API_V1}${path}`}`; if (arg.pathParams) { for (const [key, value] of Object.entries(arg.pathParams)) { const v = encodeURIComponent(String(value)); url = url.replace(`:${key}`, v).replace(`{${key}}`, v); } } if (arg.params) { const qs = new URLSearchParams(); for (const [key, value] of Object.entries(arg.params)) { if (value !== undefined && value !== null && value !== "") qs.append(key, String(value)); } const s = qs.toString(); if (s) url += (url.includes("?") ? "&" : "?") + s; } return url; } /** 当前页不是登录页时跳过去,带 next 回跳 */ export function redirectToLogin() { if (typeof window === "undefined") return; if (window.location.pathname === LOGIN_PATH) return; const next = window.location.pathname + window.location.search; window.location.assign(`${LOGIN_PATH}?next=${encodeURIComponent(next)}`); } /** * 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。 * path 可写 "/settings"(自动补 /api/v1)或完整 "/api/v1/settings"。 * queryFn: () => apiFetch("/health") * mutationFn: (body) => apiFetch("/xxx", { method: "POST", body }) */ export async function apiFetch(path: string, arg: FetcherArg = {}): Promise> { const { method = "GET", headers = {}, body, signal } = arg; const url = buildUrl(path, arg); const reqHeaders: Record = { Accept: "application/json", ...headers }; const token = getToken(); if (token) reqHeaders.Authorization = `Bearer ${token}`; if (method !== "GET") { const csrf = readCookie(CSRF_COOKIE); if (csrf) reqHeaders[CSRF_HEADER] = csrf; } 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, credentials: "include" }); } 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")); if (!arg.noAuthRedirect) redirectToLogin(); } try { return (await res.json()) as ApiResponse; } catch { return fail(res.status === 401 ? "unauthorized" : "internal", `响应不是 JSON(HTTP ${res.status})`); } } /** 带错误码的请求错误(react-query 的 error 就是它) */ export class ApiRequestError extends Error { readonly code: ApiErrorCode; constructor(code: ApiErrorCode, message: string) { super(message); this.name = "ApiRequestError"; this.code = code; } } /** 取出 data,失败时抛 ApiRequestError(给 react-query 的 queryFn / mutationFn 用) */ export async function apiData(path: string, arg?: FetcherArg): Promise { const r = await apiFetch(path, arg); if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message); return r.data; } /** 便捷方法:都返回 data,失败抛 ApiRequestError */ export const api = { get: (path: string, params?: QueryParams, signal?: AbortSignal) => apiData(path, { params, signal }), post: (path: string, body?: unknown) => apiData(path, { method: "POST", body: body ?? {} }), put: (path: string, body?: unknown) => apiData(path, { method: "PUT", body: body ?? {} }), patch: (path: string, body?: unknown) => apiData(path, { method: "PATCH", body: body ?? {} }), del: (path: string) => apiData(path, { method: "DELETE" }), }; /** 从未知错误里取人话消息(toast 用) */ export function errorMessage(err: unknown, fallback = "操作失败"): string { if (err instanceof Error && err.message) return err.message; return fallback; } /** 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;