Files
datahub/frontend/src/utils/request.ts
T
2026-05-11 10:41:36 +08:00

132 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>
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<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> {
url = `${API_BASE_URL}${url}`
const token = tokenGetter?.() ?? localStorage.getItem('access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
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: <T = unknown>(url: string, params?: Record<string, unknown>) =>
request<T>(url, { method: 'GET', params }),
post: <T = unknown>(url: string, data?: unknown) =>
request<T>(url, { method: 'POST', body: JSON.stringify(data) }),
put: <T = unknown>(url: string, data?: unknown) =>
request<T>(url, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T = unknown>(url: string, data?: unknown) =>
request<T>(url, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T = unknown>(url: string) =>
request<T>(url, { method: 'DELETE' }),
}