import axios, { type AxiosResponse, type InternalAxiosRequestConfig, type AxiosError } from 'axios' import { ElMessage } from 'element-plus' import { showToast } from 'vant' import { apiMonitor } from './monitor' function showError(message: string) { const isMobile = window.location.pathname.startsWith('/m') if (isMobile) { showToast({ message, icon: 'cross' }) } else { ElMessage.error(message) } } declare module 'axios' { export interface AxiosRequestConfig { silent?: boolean skipErrorHandler?: boolean showLoading?: boolean _startTime?: number } } // 开发环境日志记录 function logRequest(config: InternalAxiosRequestConfig) { if (import.meta.env.DEV) { console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`, { params: config.params, data: config.data, headers: config.headers, }) } // 记录请求开始时间 config._startTime = Date.now() } function logResponse(response: AxiosResponse) { if (import.meta.env.DEV) { const duration = response.config._startTime ? Date.now() - response.config._startTime : 0 console.log(`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url} (${duration}ms)`, { status: response.status, data: response.data, }) } // 记录性能指标 if (response.config._startTime) { apiMonitor.record({ url: response.config.url || '', method: response.config.method?.toUpperCase() || 'GET', duration: Date.now() - response.config._startTime, status: response.status, timestamp: Date.now(), success: true, }) } } function logError(error: AxiosError) { if (import.meta.env.DEV) { const duration = error.config?._startTime ? Date.now() - error.config._startTime : 0 console.error(`[API Error] ${error.config?.method?.toUpperCase()} ${error.config?.url} (${duration}ms)`, { status: error.response?.status, message: error.message, data: error.response?.data, }) } // 记录错误指标 if (error.config?._startTime) { apiMonitor.record({ url: error.config.url || '', method: error.config.method?.toUpperCase() || 'GET', duration: Date.now() - error.config._startTime, status: error.response?.status || 0, timestamp: Date.now(), success: false, }) } } import { clearAuthStorage, getAccessToken, getLoginPath, getRefreshToken, setAuthTokens, type AuthScope, } from '@/utils/authStorage' import type { ApiResponse } from '@/shared/types/types' export const apiClient = axios.create({ baseURL: '/api', timeout: 30000, // 增加到 30 秒,避免大文件上传超时 headers: { 'Content-Type': 'application/json', }, }) export async function unwrapData(request: Promise>>) { const { data } = await request return data.data } type RetryRequest = { resolve: (token: string) => void reject: (error: unknown) => void } type RefreshState = { refreshing: boolean pendingRequests: RetryRequest[] } type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean silent?: boolean skipErrorHandler?: boolean showLoading?: boolean } const refreshStates: Record = { user: { refreshing: false, pendingRequests: [], }, admin: { refreshing: false, pendingRequests: [], }, } function resolvePendingRequests(scope: AuthScope, token: string) { const pendingRequests = refreshStates[scope].pendingRequests.splice(0) pendingRequests.forEach(({ resolve }) => resolve(token)) } function rejectPendingRequests(scope: AuthScope, error: unknown) { const pendingRequests = refreshStates[scope].pendingRequests.splice(0) pendingRequests.forEach(({ reject }) => reject(error)) } export async function refreshAccessToken(scope: AuthScope): Promise { const refreshToken = getRefreshToken(scope) if (!refreshToken) throw new Error('no refresh token') const endpoint = scope === 'admin' ? '/api/admin/auth/refresh' : '/api/auth/refresh' const { data } = await axios.post(endpoint, { refresh_token: refreshToken }, { timeout: 10000 }) const tokens = { access_token: data.data.access_token, refresh_token: data.data.refresh_token, } setAuthTokens(scope, tokens) return tokens.access_token } function getRequestScope(url = ''): AuthScope { return url.startsWith('/admin') ? 'admin' : 'user' } function redirectToLogin(scope: AuthScope) { clearAuthStorage(scope) const currentPath = window.location.pathname + window.location.search const loginPath = getLoginPath(scope, currentPath) if (currentPath.startsWith(loginPath)) return if (scope === 'admin') { window.location.assign(loginPath) return } window.location.assign(`${loginPath}?redirect=${encodeURIComponent(currentPath)}`) } function isRefreshRequest(url = '') { return url.endsWith('/auth/refresh') || url.endsWith('/admin/auth/refresh') } apiClient.interceptors.request.use((config) => { const scope = getRequestScope(config.url || '') const token = getAccessToken(scope) if (token) { config.headers.Authorization = `Bearer ${token}` } // 记录请求日志 logRequest(config) return config }, (error) => { logError(error) return Promise.reject(error) }) apiClient.interceptors.response.use( (response) => { // 记录响应日志 logResponse(response) return response }, async (error) => { // 记录错误日志 logError(error) const originalRequest = error.config as RetriableRequestConfig | undefined if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) { // 统一错误处理,支持 skipErrorHandler 跳过 if (originalRequest && !originalRequest.silent && !originalRequest.skipErrorHandler) { const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试' showError(msg) } return Promise.reject(error) } const requestUrl = originalRequest.url || '' const scope = getRequestScope(requestUrl) if (isRefreshRequest(requestUrl)) { redirectToLogin(scope) if (originalRequest && !originalRequest.silent) { showError('登录已失效,请重新登录') } return Promise.reject(error) } const state = refreshStates[scope] if (state.refreshing) { return new Promise((resolve, reject) => { state.pendingRequests.push({ resolve, reject }) }).then((newToken) => { originalRequest._retry = true originalRequest.headers.Authorization = `Bearer ${newToken}` return apiClient(originalRequest) }) } state.refreshing = true try { const newToken = await refreshAccessToken(scope) resolvePendingRequests(scope, newToken) originalRequest._retry = true originalRequest.headers.Authorization = `Bearer ${newToken}` return apiClient(originalRequest) } catch (refreshError) { rejectPendingRequests(scope, refreshError) redirectToLogin(scope) if (originalRequest && !originalRequest.silent) { showError('会话已过期,请重新登录') } return Promise.reject(refreshError) } finally { state.refreshing = false } }, )