Files
UHD_Media_Tool/apps/web/app/lib/api.ts
T
umtandClaude Fable 5.1 b22c440139 chore: scaffold monorepo and migrate uhd_web design system (W0-A)
- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 17:36:26 +08:00

108 lines
3.7 KiB
TypeScript

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<B = unknown> = {
/** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */
pathParams?: Record<string, string | number>;
params?: Record<string, string | number | boolean | undefined | null>;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: B;
headers?: Record<string, string>;
signal?: AbortSignal;
};
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const fail = (code: ApiError["code"], message: string): ApiResponse<never> => ({
ok: false,
error: { code, message },
});
/**
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
* queryFn: () => apiFetch<HealthStatus>("/api/v1/health")
* mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body })
*/
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
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<string, string> = { ...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<T>;
} catch {
return fail("internal", `响应不是 JSON(HTTP ${res.status})`);
}
}
/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */
export async function apiData<T>(path: string, arg?: FetcherArg): Promise<T> {
const r = await apiFetch<T>(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<B = unknown, T = unknown>(path: string, base?: Omit<FetcherArg<B>, "body">) {
return (body: B) => apiFetch<T>(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;