import { ApiError } from '@/types/api' const DEFAULT_TIMEOUT = 15000 // 允许外部注入 token 获取函数,避免与 store 循环依赖 let tokenGetter: (() => string | null) | null = null export function setTokenGetter(fn: () => string | null) { tokenGetter = fn } interface RequestOptions extends RequestInit { params?: Record timeout?: number } // 运行期配置优先(容器启动时由 nginx entrypoint 注入到 /config.js 写入 window.__APP_CONFIG__) // 构建期 VITE_API_BASE_URL 作为本地 dev 的 fallback function resolveApiBaseUrl(): string { if (typeof window !== 'undefined') { const runtime = (window as unknown as { __APP_CONFIG__?: { apiBaseUrl?: string } }).__APP_CONFIG__ const url = runtime?.apiBaseUrl // 占位符未替换时(如本地直接打开 public/config.js)忽略 if (url && !(url.startsWith('__') && url.endsWith('__'))) { return url } } return import.meta.env.VITE_API_BASE_URL || '' } const API_BASE_URL = resolveApiBaseUrl() async function request(url: string, options: RequestOptions = {}): Promise { url = `${API_BASE_URL}${url}` const token = tokenGetter?.() ?? localStorage.getItem('access_token') const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), } if (token) { headers['Authorization'] = `Bearer ${token}` } // 拼接查询参数 if (options.params) { const query = new URLSearchParams( Object.entries(options.params) .filter(([, v]) => v !== undefined && v !== null && v !== '') .map(([k, v]) => [k, String(v)]) ) const separator = url.includes('?') ? '&' : '?' url = `${url}${separator}${query}` } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { params: _params, timeout: _timeout, ...fetchOptions } = options // 超时控制 const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), options.timeout ?? DEFAULT_TIMEOUT) try { const response = await fetch(url, { ...fetchOptions, headers, signal: controller.signal, }) // 401 未授权:登录/注册接口解析后端错误信息,其余接口重定向到登录页 if (response.status === 401) { const isAuthEndpoint = /\/api\/v1\/(login|register)$/.test(url) if (!isAuthEndpoint) { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') localStorage.removeItem('user') const redirect = encodeURIComponent( window.location.pathname + window.location.search, ) window.location.href = `/login?redirect=${redirect}` throw new ApiError('登录已过期,请重新登录', 401) } let msg = '用户名或密码错误' try { const body = await response.json() if (body.message) msg = body.message } catch { /* ignore parse error */ } throw new ApiError(msg, 401) } // 防护非 JSON 响应(如网关 HTML 错误页) let result try { result = await response.json() } catch { throw new ApiError('服务器返回了非预期的响应格式', response.status) } // 业务错误 if (result.code !== 0) { throw new ApiError(result.message || '请求失败', result.code) } return result.data } catch (error) { if (error instanceof ApiError) throw error if (error instanceof DOMException && error.name === 'AbortError') { throw new ApiError('请求超时,请检查网络连接', 0) } throw error } finally { clearTimeout(timeoutId) } } export const api = { get: (url: string, params?: Record) => request(url, { method: 'GET', params }), post: (url: string, data?: unknown) => request(url, { method: 'POST', body: JSON.stringify(data) }), put: (url: string, data?: unknown) => request(url, { method: 'PUT', body: JSON.stringify(data) }), patch: (url: string, data?: unknown) => request(url, { method: 'PATCH', body: JSON.stringify(data) }), delete: (url: string) => request(url, { method: 'DELETE' }), }