feat(web): 搭建 Web 壳层与 UI 包基线

React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
This commit is contained in:
Stev_Wang committed 2026-11-02 17:21:16 +08:00
1 parent 23067edb8f
commit 073d210a92
51 files changed
+4452

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
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);
}