React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
200 lines
5.4 KiB
TypeScript
200 lines
5.4 KiB
TypeScript
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);
|
||
}
|