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:
1 parent
23067edb8f
commit
073d210a92
51 files changed
+4452
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { ApiError, request, setUnauthorizedHandler } from './client';
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const emptyResponse = (status: number): Response => new Response(null, { status });
|
||||
|
||||
function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
|
||||
permissions: [],
|
||||
});
|
||||
}
|
||||
|
||||
function authorizationOf(init: RequestInit | undefined): string | null {
|
||||
return init?.headers instanceof Headers ? init.headers.get('Authorization') : null;
|
||||
}
|
||||
|
||||
function toUrl(input: string | URL | Request | undefined): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
return input?.url ?? '';
|
||||
}
|
||||
|
||||
describe('请求层', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
it('注入 Bearer token 并解析 JSON', async () => {
|
||||
signIn();
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ status: 'ok' }));
|
||||
|
||||
await expect(request<{ status: string }>('/health')).resolves.toEqual({ status: 'ok' });
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(authorizationOf(init)).toBe('Bearer token-1');
|
||||
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toBe('http://127.0.0.1:3000/api/v1/health');
|
||||
});
|
||||
|
||||
it('auth: false 时不注入 token', async () => {
|
||||
signIn();
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||
await request('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email: 'a', password: 'b' },
|
||||
});
|
||||
expect(authorizationOf(fetchMock.mock.calls[0]?.[1])).toBeNull();
|
||||
});
|
||||
|
||||
it('204 返回 undefined', async () => {
|
||||
signIn();
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(emptyResponse(204));
|
||||
await expect(request('/resource')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('统一错误体解析为 ApiError,保留 trace_id', async () => {
|
||||
signIn();
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ code: 42201, message: '业务校验失败', details: { field: 'name' }, trace_id: 'trace-1' },
|
||||
422,
|
||||
),
|
||||
);
|
||||
const error = await request('/resource').catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
const apiError = error instanceof ApiError ? error : null;
|
||||
expect(apiError?.code).toBe(42201);
|
||||
expect(apiError?.status).toBe(422);
|
||||
expect(apiError?.traceId).toBe('trace-1');
|
||||
expect(apiError?.details).toEqual({ field: 'name' });
|
||||
});
|
||||
|
||||
it('401 时单飞刷新并重放原请求', async () => {
|
||||
signIn('expired-token', 'refresh-1');
|
||||
let refreshCalls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
|
||||
const url = toUrl(input);
|
||||
const authorization = authorizationOf(init);
|
||||
if (url.endsWith('/auth/refresh')) {
|
||||
refreshCalls += 1;
|
||||
return Promise.resolve(
|
||||
jsonResponse({ accessToken: 'new-token', refreshToken: 'refresh-2' }),
|
||||
);
|
||||
}
|
||||
if (authorization === 'Bearer expired-token') {
|
||||
return Promise.resolve(emptyResponse(401));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({ ok: true }));
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([request('/a'), request('/b')]);
|
||||
expect(first).toEqual({ ok: true });
|
||||
expect(second).toEqual({ ok: true });
|
||||
expect(refreshCalls).toBe(1);
|
||||
expect(useAuthStore.getState().accessToken).toBe('new-token');
|
||||
});
|
||||
|
||||
it('刷新失败时清空会话并触发未授权回调', async () => {
|
||||
signIn('expired-token', 'refresh-1');
|
||||
const onUnauthorized = vi.fn();
|
||||
setUnauthorizedHandler(onUnauthorized);
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = toUrl(input);
|
||||
return Promise.resolve(
|
||||
url.endsWith('/auth/refresh') ? emptyResponse(401) : emptyResponse(401),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(request('/secure')).rejects.toBeInstanceOf(ApiError);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('把请求体序列化为 JSON', async () => {
|
||||
signIn();
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||
await request('/resource', { method: 'POST', body: { name: '素材' } });
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
expect(init?.body).toBe(JSON.stringify({ name: '素材' }));
|
||||
expect(init?.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 健康检查
|
||||
* @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。
|
||||
*/
|
||||
get: operations['HealthController_check'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
ComponentHealthDto: {
|
||||
/**
|
||||
* @example up
|
||||
* @enum {string}
|
||||
*/
|
||||
status: 'up' | 'down';
|
||||
/**
|
||||
* @description 检查耗时(毫秒)
|
||||
* @example 3
|
||||
*/
|
||||
latencyMs: number;
|
||||
/** @description 失败原因(仅 down 时返回) */
|
||||
error?: string;
|
||||
};
|
||||
HealthChecksDto: {
|
||||
database: components['schemas']['ComponentHealthDto'];
|
||||
redis: components['schemas']['ComponentHealthDto'];
|
||||
};
|
||||
HealthResponseDto: {
|
||||
/**
|
||||
* @example ok
|
||||
* @enum {string}
|
||||
*/
|
||||
status: 'ok' | 'degraded';
|
||||
/** @example development */
|
||||
env: string;
|
||||
/** @example 0.1.0 */
|
||||
version: string;
|
||||
/**
|
||||
* @description 进程运行秒数
|
||||
* @example 128
|
||||
*/
|
||||
uptimeSeconds: number;
|
||||
checks: components['schemas']['HealthChecksDto'];
|
||||
/** @example 2026-11-02T08:00:00.000Z */
|
||||
timestamp: string;
|
||||
};
|
||||
ErrorResponseDto: {
|
||||
/**
|
||||
* @description 错误码(见 ErrorCode 常量)
|
||||
* @example 42202
|
||||
*/
|
||||
code: number;
|
||||
/**
|
||||
* @description 可直接展示给用户的提示
|
||||
* @example 内容触发合规拦截
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* @description 补充信息(字段错误、命中规则等),结构由错误码决定
|
||||
* @example {
|
||||
* "issues": [
|
||||
* {
|
||||
* "path": "name",
|
||||
* "message": "Required"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
details?: Record<string, never>;
|
||||
/**
|
||||
* @description 请求追踪 ID,与日志同源
|
||||
* @example 01JABCDEF0123456789ABCDEFG
|
||||
*/
|
||||
trace_id: string;
|
||||
};
|
||||
/**
|
||||
* @description 业务错误码(只允许使用 @lingxi/shared 中登记的错误码)
|
||||
* @enum {integer}
|
||||
*/
|
||||
ErrorCode:
|
||||
| 40001
|
||||
| 40101
|
||||
| 40102
|
||||
| 40103
|
||||
| 40301
|
||||
| 40302
|
||||
| 40303
|
||||
| 40304
|
||||
| 40401
|
||||
| 40901
|
||||
| 40902
|
||||
| 40903
|
||||
| 42201
|
||||
| 42202
|
||||
| 42203
|
||||
| 42901
|
||||
| 42902
|
||||
| 50001
|
||||
| 50002
|
||||
| 50003;
|
||||
ErrorResponse: {
|
||||
code: components['schemas']['ErrorCode'];
|
||||
message: string;
|
||||
/** @description 补充信息,结构由错误码决定 */
|
||||
details?: unknown;
|
||||
trace_id: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 全部依赖健康 */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HealthResponseDto'];
|
||||
};
|
||||
};
|
||||
/** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { notifyError } from '@lingxi/ui';
|
||||
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ApiError } from './client';
|
||||
|
||||
function reportError(error: unknown): void {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 401) {
|
||||
return;
|
||||
}
|
||||
notifyError(error.message, error.traceId);
|
||||
return;
|
||||
}
|
||||
notifyError(error instanceof Error ? error.message : '网络请求失败,请稍后重试');
|
||||
}
|
||||
|
||||
function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
if (failureCount >= 2) {
|
||||
return false;
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return error.status >= 500 || error.status === 429;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({ onError: reportError }),
|
||||
mutationCache: new MutationCache({ onError: reportError }),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: shouldRetry,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user