/** * 共享 HTTP 客户端(公开站 + Admin 统一) * * 特性: * - 统一超时(默认 30s,可覆盖) * - 统一错误处理(ApiError) * - 401 自动广播(Admin 场景) * - JSON/HTML 容错解析 * - 请求竞态防护(可选 signal) */ import { UNAUTHORIZED_EVENT } from '../admin/api' const API_BASE = '/api/v1' const DEFAULT_TIMEOUT = 30_000 export class ApiError extends Error { constructor( message: string, public status: number, public data?: unknown, ) { super(message) this.name = 'ApiError' } } interface RequestOptions { timeoutMs?: number skipAuthHandling?: boolean signal?: AbortSignal method?: string body?: string } async function request(path: string, options: RequestOptions = {}): Promise { const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}` const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) // 如果外部传了 signal,也关联到内部 controller if (signal) { signal.addEventListener('abort', () => controller.abort(), { once: true }) } try { const res = await fetch(url, { signal: controller.signal, headers: { 'Content-Type': 'application/json' }, }) if (!res.ok) { const rawText = await res.text() let detail: unknown = rawText try { detail = JSON.parse(rawText) } catch { // 非 JSON(如 HTML 错误页),保留原始文本 } let message = detail && typeof detail === 'object' && detail !== null && 'detail' in detail ? String((detail as { detail: unknown }).detail) : `HTTP ${res.status}: ${res.statusText}` if (res.status === 401 && !skipAuthHandling) { message += '\n登录已过期,请重新登录。' window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)) } throw new ApiError(message, res.status, detail) } if (res.status === 204) { return undefined as T } return res.json() as Promise } catch (err) { if (err instanceof ApiError) throw err if (err instanceof DOMException && err.name === 'AbortError') { throw new ApiError('请求超时,请稍后重试', 0) } throw new ApiError( err instanceof Error ? err.message : '网络错误,请检查连接', 0, ) } finally { clearTimeout(timer) } } export const http = { get: (path: string, opts?: RequestOptions) => request(path, { ...opts }), post: (path: string, body?: unknown, opts?: RequestOptions) => request(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }), put: (path: string, body?: unknown, opts?: RequestOptions) => request(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }), delete: (path: string, opts?: RequestOptions) => request(path, { ...opts, method: 'DELETE' }), }