Files
lingxi/apps/web/src/api/client.ts
T
Stev_Wang 073d210a92 feat(web): 搭建 Web 壳层与 UI 包基线
React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
2026-11-02 17:21:16 +08:00

200 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiErrorBodySchema } from '@lingxi/shared';
import { useAuthStore } from '@/stores/auth-store';
const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface RequestOptions {
method?: HttpMethod;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
/** 登录、刷新等接口不需要携带 token */
auth?: boolean;
}
export interface ApiErrorInit {
code: number;
status: number;
message: string;
details?: unknown;
traceId?: string;
}
/** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */
export class ApiError extends Error {
readonly code: number;
readonly status: number;
readonly details?: unknown;
readonly traceId?: string;
constructor(init: ApiErrorInit) {
super(init.message);
this.name = 'ApiError';
this.code = init.code;
this.status = init.status;
this.details = init.details;
this.traceId = init.traceId;
}
}
let unauthorizedHandler: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null): void {
unauthorizedHandler = handler;
}
function baseUrl(): string {
const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL;
return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
}
function buildUrl(path: string): string {
if (/^https?:\/\//.test(path)) {
return path;
}
return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
}
function serializeBody(body: unknown): BodyInit | undefined {
if (body === undefined) {
return undefined;
}
if (
typeof body === 'string' ||
body instanceof FormData ||
body instanceof Blob ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
return JSON.stringify(body);
}
function fallbackCode(status: number): number {
if (status === 401) return 40101;
if (status === 403) return 40301;
if (status === 404) return 40401;
if (status === 409) return 40903;
if (status === 429) return 42901;
if (status >= 500) return 50001;
return 40001;
}
async function toApiError(response: Response): Promise<ApiError> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
payload = null;
}
const parsed = apiErrorBodySchema.safeParse(payload);
if (parsed.success) {
return new ApiError({
code: parsed.data.code,
status: response.status,
message: parsed.data.message,
details: parsed.data.details,
traceId: parsed.data.trace_id,
});
}
return new ApiError({
code: fallbackCode(response.status),
status: response.status,
message: `请求失败(HTTP ${response.status})`,
});
}
let refreshPromise: Promise<boolean> | null = null;
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
export function refreshSession(): Promise<boolean> {
refreshPromise ??= doRefresh().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function doRefresh(): Promise<boolean> {
const { refreshToken, clearSession } = useAuthStore.getState();
if (refreshToken === null) {
return false;
}
let response: Response;
try {
response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
} catch {
return false;
}
if (!response.ok) {
clearSession();
unauthorizedHandler?.();
return false;
}
const payload: unknown = await response.json().catch(() => null);
const accessToken =
typeof payload === 'object' && payload !== null && 'accessToken' in payload
? payload.accessToken
: null;
const nextRefreshToken =
typeof payload === 'object' && payload !== null && 'refreshToken' in payload
? payload.refreshToken
: null;
if (typeof accessToken !== 'string' || accessToken.length === 0) {
clearSession();
unauthorizedHandler?.();
return false;
}
useAuthStore.getState().setTokens({
accessToken,
refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken,
});
return true;
}
async function send<T>(path: string, options: RequestOptions, allowRefresh: boolean): Promise<T> {
const { accessToken } = useAuthStore.getState();
const headers = new Headers(options.headers);
const body = serializeBody(options.body);
if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (options.auth !== false && accessToken !== null) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const response = await fetch(buildUrl(path), {
method: options.method ?? 'GET',
headers,
body,
signal: options.signal,
});
if (response.status === 401 && allowRefresh && options.auth !== false) {
const refreshed = await refreshSession();
if (refreshed) {
return send<T>(path, options, false);
}
}
if (!response.ok) {
throw await toApiError(response);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
/** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
return send<T>(path, options, true);
}