import axios from 'axios' import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, } from 'axios' import NProgress from 'nprogress' import 'nprogress/nprogress.css' import '@/styles/nprogress.css' import { apiConfig } from '@/config/env' import type { ApiResponse } from '@/types/api' import { getStoredAccessToken, getStoredRefreshToken, REFRESH_TOKEN_URL, refreshAccessToken, type AuthTokenResponse, } from '@/utils/auth-token' import { handleAuthFailure } from '@/utils/auth-failure' import { showAppToast } from '@/utils/toast' // 关闭 NProgress 右侧旋转小圆圈,保持进度条简洁 NProgress.configure({ showSpinner: false }) interface AuthRequestConfig extends InternalAxiosRequestConfig { skipAuth?: boolean _retry?: boolean } interface AuthAxiosRequestConfig extends AxiosRequestConfig { skipAuth?: boolean _retry?: boolean } // 创建 axios 实例,baseURL 和 timeout 从环境变量配置中读取 const service: AxiosInstance = axios.create({ baseURL: apiConfig.baseURL, // 接口基础地址,由 .env 文件中的 VITE_API_BASE_URL 决定 timeout: apiConfig.timeout, // 请求超时时间(毫秒),默认 10000 }) /** * 请求拦截器 * 在每个请求发出前执行: * 1. 启动顶部进度条 * 2. 自动携带 JWT token 到请求头 */ service.interceptors.request.use( (config: AuthRequestConfig) => { // 开始加载进度条 NProgress.start() // 从 Pinia 读取 token,存在则添加到请求头 const token = getStoredAccessToken() if (!config.skipAuth && token) { config.headers.Authorization = `Bearer ${token}` } return config }, // 请求配置出错时(如网络断开),结束进度条并抛出错误 (error) => { NProgress.done() return Promise.reject(error) }, ) /** * 响应拦截器 * 在收到响应后执行: * 1. 结束进度条 * 2. 判断后端业务 code,非 0 视为业务失败 * 3. HTTP 状态码异常时给出中文提示 * 4. 401/0401 优先刷新 token,无法恢复时清理登录态并提示重新登录 */ service.interceptors.response.use( // ——— 成功响应(HTTP 2xx)——— async (response: AxiosResponse) => { NProgress.done() // 普通业务接口返回 { code, message, data };OAuth 登录接口直接返回 token 结构。 const res = response.data const originalConfig = response.config as AuthAxiosRequestConfig // TAM 会用 HTTP 200 返回认证失败,需要按 401 的相同规则刷新 token 并重试。 if (isUnauthorizedResponse(res) && !originalConfig.skipAuth) { if (shouldRefreshToken(originalConfig)) { let token: string try { token = await refreshAccessToken(requestRefreshTokenByAxios) } catch (refreshError) { void handleAuthFailure() return Promise.reject(refreshError) } return retryRequestWithToken(originalConfig, token) } void handleAuthFailure() return Promise.reject(new Error(getUnauthorizedMessage(res))) } // 只有响应体明确包含 code 字段时,才按统一业务格式判断,兼容 0 和 "0000" 两种成功码。 if (typeof res.code !== 'undefined' && !isSuccessCode(res.code)) { showAppToast(res.message || '请求失败') // 将业务错误抛出,调用方可通过 catch 捕获 return Promise.reject(new Error(res.message || '请求失败')) } // 业务成功,直接返回 data 字段,调用方无需再解包 response.data.data return res as unknown as AxiosResponse }, // ——— 失败响应(HTTP 非 2xx 或网络错误)——— async (error: AxiosError) => { NProgress.done() const originalConfig = error.config as AuthAxiosRequestConfig | undefined if (error.response?.status === 401 && originalConfig && !originalConfig.skipAuth) { if (shouldRefreshToken(originalConfig)) { let token: string try { token = await refreshAccessToken(requestRefreshTokenByAxios) } catch (refreshError) { void handleAuthFailure() return Promise.reject(refreshError) } return retryRequestWithToken(originalConfig, token) } void handleAuthFailure() return Promise.reject(error) } // HTTP 状态码 → 中文提示映射表 const statusCodeMap: Record = { 400: '请求参数错误', 401: '未授权,请重新登录', 403: '拒绝访问', 404: '请求资源不存在', 500: '服务器内部错误', } // 优先取映射表中的提示,没有则显示状态码;无 response 说明是网络异常 const status = error.response?.status as number const message = error.response ? statusCodeMap[status] || `连接错误 ${status}` : '网络连接异常' showAppToast(message) return Promise.reject(error) }, ) function shouldRefreshToken(config: AuthAxiosRequestConfig) { return !config.skipAuth && !config._retry && Boolean(getStoredRefreshToken()) } function isSuccessCode(code: ApiResponse['code']) { return code === 0 || code === '0' || code === '0000' } function retryRequestWithToken(config: AuthAxiosRequestConfig, token: string) { config._retry = true config.headers = { ...config.headers, Authorization: `Bearer ${token}`, } return service.request(config) } function isUnauthorizedResponse(value: unknown) { if (!isRecord(value)) return false if (isUnauthorizedCode(value.code) || isUnauthorizedCode(value.retCode)) return true const head = isRecord(value.head) ? value.head : undefined return isUnauthorizedCode(head?.retCode) } function getUnauthorizedMessage(value: unknown) { if (!isRecord(value)) return '认证失败' const head = isRecord(value.head) ? value.head : undefined const message = head?.retMsg || value.message return typeof message === 'string' && message.trim() ? message : '认证失败' } function isUnauthorizedCode(code: unknown) { return code === 401 || code === '401' || code === '0401' } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } async function requestRefreshTokenByAxios() { const refreshToken = getStoredRefreshToken() if (!refreshToken) { throw new Error('本地不存在 refresh token') } const response = await axios.request & AuthTokenResponse>({ baseURL: apiConfig.baseURL, timeout: apiConfig.timeout, url: REFRESH_TOKEN_URL, method: 'POST', data: { refresh_token: refreshToken, }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }) const result = response.data if (typeof result.code === 'number' && result.code !== 0) { throw new Error(result.message || '刷新 token 失败') } return result.data || result } /** * 请求配置接口 * @property url - 请求地址(必填) * @property method - 请求方法,默认 GET * @property data - 请求体(POST/PUT/PATCH 时使用) * @property params - URL 查询参数(拼接到 ?key=value) */ export interface RequestConfig { url: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' data?: T params?: Record headers?: Record auth?: { username: string; password: string } // HTTP Basic Auth skipAuth?: boolean } /** * 通用请求函数 * @typeParam R - 响应 data 的类型(后端返回的 data 字段类型) * @typeParam T - 请求参数的类型(data 的类型) * @param config - 请求配置对象 * @returns Promise,resolve 后直接拿到 ApiResponse * * @example * // GET 请求 * request({ url: '/user/1' }) * * // POST 请求 * request<{ token: string }, LoginParams>({ * url: '/login', * method: 'POST', * data: { username: 'admin', password: '123' } * }) */ export function request( config: RequestConfig, ): Promise> { const axiosConfig: AuthAxiosRequestConfig = { url: config.url, method: config.method || 'GET', data: config.data, params: config.params, headers: config.headers, auth: config.auth, skipAuth: config.skipAuth, } return service.request>(axiosConfig) as unknown as Promise> } // 同时导出 axios 实例,供需要直接使用 axios 原生方法的场景(如文件上传) export default service