2026-03-18 13:53:36 +08:00
|
|
|
|
import { ApiError } from '@/types/api'
|
|
|
|
|
|
|
2026-03-18 14:55:37 +08:00
|
|
|
|
const DEFAULT_TIMEOUT = 15000
|
|
|
|
|
|
|
|
|
|
|
|
// 允许外部注入 token 获取函数,避免与 store 循环依赖
|
|
|
|
|
|
let tokenGetter: (() => string | null) | null = null
|
|
|
|
|
|
export function setTokenGetter(fn: () => string | null) {
|
|
|
|
|
|
tokenGetter = fn
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 13:53:36 +08:00
|
|
|
|
interface RequestOptions extends RequestInit {
|
|
|
|
|
|
params?: Record<string, unknown>
|
2026-03-18 14:55:37 +08:00
|
|
|
|
timeout?: number
|
2026-03-18 13:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 10:41:36 +08:00
|
|
|
|
// 运行期配置优先(容器启动时由 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()
|
2026-03-23 09:46:34 +08:00
|
|
|
|
|
2026-03-18 13:53:36 +08:00
|
|
|
|
async function request<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> {
|
2026-03-23 09:46:34 +08:00
|
|
|
|
url = `${API_BASE_URL}${url}`
|
2026-03-18 14:55:37 +08:00
|
|
|
|
const token = tokenGetter?.() ?? localStorage.getItem('access_token')
|
2026-03-18 13:53:36 +08:00
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 20:08:04 +08:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
2026-03-18 14:55:37 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-23 13:20:19 +08:00
|
|
|
|
// 401 未授权:登录/注册接口解析后端错误信息,其余接口重定向到登录页
|
2026-03-18 14:55:37 +08:00
|
|
|
|
if (response.status === 401) {
|
2026-03-23 09:46:34 +08:00
|
|
|
|
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}`
|
2026-03-23 13:20:19 +08:00
|
|
|
|
throw new ApiError('登录已过期,请重新登录', 401)
|
2026-03-23 09:46:34 +08:00
|
|
|
|
}
|
2026-03-23 13:20:19 +08:00
|
|
|
|
let msg = '用户名或密码错误'
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = await response.json()
|
|
|
|
|
|
if (body.message) msg = body.message
|
|
|
|
|
|
} catch { /* ignore parse error */ }
|
|
|
|
|
|
throw new ApiError(msg, 401)
|
2026-03-18 14:55:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 防护非 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)
|
2026-03-18 13:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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' }),
|
|
|
|
|
|
}
|