feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { getPublicApiOrigin } from '@/lib/env';
import { emit401Unauthorized, emit403Forbidden } from '@/lib/notify/auth-events';
import { refreshTokensShared } from '@/lib/api/refresh-flight';
import { ApiError, type ApiEnvelope } from '@/lib/api/types';
type GetTokens = () => { accessToken: string | null; refreshToken: string | null };
type SetTokens = (access: string | null, refresh: string | null) => void;
let tokenBridge: { get: GetTokens; set: SetTokens } | null = null;
/** 由 auth store 在客户端挂载时注册,避免循环依赖。 */
export function registerTokenBridge(get: GetTokens, set: SetTokens) {
tokenBridge = { get, set };
}
let tenantIdGetter: () => string | null = () => null;
/** 由 tenant store 注册,请求 `/api/v1` 时附加 `X-Tenant-ID`(可选)。 */
export function registerTenantHeaderBridge(get: () => string | null) {
tenantIdGetter = get;
}
function authHeader(): HeadersInit {
const t = tokenBridge?.get().accessToken;
return t ? { Authorization: `Bearer ${t}` } : {};
}
function tenantHeader(): HeadersInit {
const tid = tenantIdGetter?.();
return tid ? { 'X-Tenant-ID': tid } : {};
}
/** 业务 JSONHTTP 200 时解析 envelope401 时共享 refresh 后重试一次(登录请求勿走此逻辑)。 */
export async function apiJson<T>(
path: string,
init: RequestInit & { skipRefreshRetry?: boolean } = {}
): Promise<T> {
const { skipRefreshRetry, ...req } = init;
const url = `${getPublicApiOrigin()}${path.startsWith('/') ? path : `/${path}`}`;
const method = (req.method || 'GET').toUpperCase();
const hasBody = req.body != null && req.body !== '';
const jsonHeaders: HeadersInit =
method !== 'GET' && method !== 'HEAD' && hasBody ? { 'Content-Type': 'application/json' } : {};
const baseHeaders = {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
};
const first = await fetch(url, {
...req,
headers: baseHeaders,
credentials: 'include',
});
if (first.status === 401 && !skipRefreshRetry && tokenBridge) {
const ok = await refreshTokensShared(
() => tokenBridge!.get().refreshToken,
(a, r) => tokenBridge!.set(a, r)
);
if (ok) {
const second = await fetch(url, {
...req,
headers: {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
},
credentials: 'include',
});
return handleEnvelope<T>(second);
}
}
return handleEnvelope<T>(first);
}
async function handleEnvelope<T>(res: Response): Promise<T> {
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
throw new ApiError('响应不是合法 JSON', res.status, text);
}
if (res.status === 401) {
const msg = (body as { msg?: string })?.msg || '未授权';
emit401Unauthorized(msg);
throw new ApiError(msg, 401, body);
}
if (res.status === 403) {
const msg = (body as { msg?: string })?.msg || '禁止访问';
emit403Forbidden(msg);
throw new ApiError(msg, 403, body);
}
if (!res.ok) {
throw new ApiError(`HTTP ${res.status}`, res.status, body);
}
const env = body as ApiEnvelope<T>;
if (typeof env?.code !== 'number') {
return body as T;
}
if (env.code !== 200) {
throw new ApiError(env.msg || '业务失败', res.status, env);
}
return env.data as T;
}